mock-service-cli 4.1.0 → 4.3.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.
@@ -83,6 +83,7 @@
83
83
  flex: 1;
84
84
  display: flex;
85
85
  flex-direction: column;
86
+ min-height: 0;
86
87
  }
87
88
 
88
89
  .toolbar {
@@ -156,6 +157,50 @@
156
157
  background: #e0e0e0;
157
158
  }
158
159
 
160
+ .toolbar-action-btn {
161
+ padding: 6px 10px;
162
+ background: #f0f0f0;
163
+ border: none;
164
+ border-radius: 4px;
165
+ font-size: 12px;
166
+ cursor: pointer;
167
+ transition: all 0.2s;
168
+ }
169
+
170
+ .toolbar-action-btn:hover:not(:disabled) {
171
+ background: #e0e0e0;
172
+ }
173
+
174
+ .toolbar-action-btn.danger:hover:not(:disabled) {
175
+ background: #dc3545;
176
+ color: white;
177
+ }
178
+
179
+ .toolbar-action-btn:disabled {
180
+ opacity: 0.45;
181
+ cursor: not-allowed;
182
+ }
183
+
184
+ body:not(.editing) .edit-only {
185
+ display: none !important;
186
+ }
187
+
188
+ body.editing .edit-inline {
189
+ display: inline-flex !important;
190
+ }
191
+
192
+ body.editing .edit-inline-block {
193
+ display: inline-block !important;
194
+ }
195
+
196
+ body.editing .edit-flex {
197
+ display: flex !important;
198
+ }
199
+
200
+ body.editing .edit-block {
201
+ display: block !important;
202
+ }
203
+
159
204
  .back-btn {
160
205
  padding: 6px 12px;
161
206
  background: #f0f0f0;
@@ -290,18 +335,33 @@
290
335
  }
291
336
 
292
337
  .file-grid {
293
- padding: 8px;
338
+ position: relative;
339
+ overflow: auto;
340
+ flex: 1;
341
+ min-height: 0;
342
+ }
343
+
344
+ .virtual-spacer {
345
+ width: 1px;
346
+ pointer-events: none;
347
+ }
348
+
349
+ .virtual-content {
350
+ position: absolute;
351
+ top: 0;
352
+ left: 0;
353
+ right: 0;
354
+ }
355
+
356
+ .virtual-content.grid-view {
294
357
  display: grid;
295
- grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
296
358
  gap: 8px;
297
- overflow: auto;
359
+ grid-auto-rows: 124px;
360
+ padding: 8px;
298
361
  }
299
362
 
300
- .file-grid.list-view {
301
- grid-template-columns: 1fr;
302
- gap: 0;
303
- padding: 4px 0;
304
- flex: unset;
363
+ .virtual-content.list-view {
364
+ display: block;
305
365
  }
306
366
 
307
367
  .file-item {
@@ -314,10 +374,19 @@
314
374
  transition: all 0.2s;
315
375
  }
316
376
 
377
+ .grid-view .file-item {
378
+ height: 124px;
379
+ }
380
+
317
381
  .file-item:hover {
318
382
  background: #f8f9fa;
319
383
  }
320
384
 
385
+ .file-item.selected {
386
+ background: #eef2ff;
387
+ outline: 1px solid #667eea;
388
+ }
389
+
321
390
  .list-view .file-item {
322
391
  flex-direction: row;
323
392
  padding: 6px 12px;
@@ -348,6 +417,17 @@
348
417
  height: 100%;
349
418
  }
350
419
 
420
+ .file-select {
421
+ align-self: flex-start;
422
+ margin-bottom: 6px;
423
+ cursor: pointer;
424
+ }
425
+
426
+ .list-view .file-select {
427
+ align-self: center;
428
+ margin: 0 8px 0 0;
429
+ }
430
+
351
431
  .file-info {
352
432
  text-align: center;
353
433
  word-break: break-all;
@@ -369,6 +449,13 @@
369
449
  line-height: 1.3;
370
450
  }
371
451
 
452
+ .grid-view .file-name {
453
+ display: -webkit-box;
454
+ -webkit-box-orient: vertical;
455
+ -webkit-line-clamp: 2;
456
+ overflow: hidden;
457
+ }
458
+
372
459
  .list-view .file-name {
373
460
  margin-bottom: 0;
374
461
  flex: 1;
@@ -426,6 +513,16 @@
426
513
  color: white;
427
514
  }
428
515
 
516
+ .action-btn.rename:hover {
517
+ background: #ffc107;
518
+ color: #333;
519
+ }
520
+
521
+ .action-btn.delete:hover {
522
+ background: #dc3545;
523
+ color: white;
524
+ }
525
+
429
526
  .modal {
430
527
  display: none;
431
528
  position: fixed;
@@ -746,6 +843,25 @@
746
843
  返回上级
747
844
  </button>
748
845
  <button class="refresh-btn" onclick="loadDirectory(currentPath)" title="刷新">🔄</button>
846
+ <button
847
+ class="toolbar-action-btn edit-only edit-inline"
848
+ onclick="createEntry('directory')"
849
+ title="新建文件夹"
850
+ >
851
+ 新建文件夹
852
+ </button>
853
+ <button class="toolbar-action-btn edit-only edit-inline" onclick="createEntry('file')" title="新建文件">
854
+ 新建文件
855
+ </button>
856
+ <button
857
+ class="toolbar-action-btn danger edit-only edit-inline"
858
+ id="deleteSelectedBtn"
859
+ onclick="deleteSelectedPaths()"
860
+ title="删除选中项"
861
+ disabled
862
+ >
863
+ 删除选中
864
+ </button>
749
865
  <button
750
866
  class="toggle-hidden-btn"
751
867
  id="toggleHiddenBtn"
@@ -803,6 +919,13 @@
803
919
  🖥️ 在文件管理器中打开
804
920
  </div>
805
921
  <div class="context-menu-item" id="ctxDownload" onclick="handleContextMenuAction('download')">⬇️ 下载</div>
922
+ <div class="context-menu-divider edit-only edit-block"></div>
923
+ <div class="context-menu-item edit-only edit-flex" id="ctxRename" onclick="handleContextMenuAction('rename')">
924
+ ✏️ 重命名
925
+ </div>
926
+ <div class="context-menu-item edit-only edit-flex" id="ctxDelete" onclick="handleContextMenuAction('delete')">
927
+ 🗑️ 删除
928
+ </div>
806
929
  </div>
807
930
 
808
931
  <script>
@@ -816,11 +939,24 @@
816
939
  let showHiddenFiles = true;
817
940
  let showBirthtime = false;
818
941
  let currentPreviewContent = '';
942
+ let currentPreviewObjectUrl = null;
819
943
  let currentZoom = 1;
820
944
  let currentRotation = 0;
821
945
  let currentContextMenuFile = null;
822
946
  let currentImageIndex = -1;
823
947
  let imageFiles = [];
948
+ let selectedPaths = new Set();
949
+ let editMode = false;
950
+ let directoryRequestController = null;
951
+ let directoryRequestId = 0;
952
+ let virtualRenderFrame = null;
953
+ let virtualState = null;
954
+ let fileGridResizeObserver = null;
955
+
956
+ const LIST_ROW_HEIGHT = 37;
957
+ const GRID_ROW_HEIGHT = 132;
958
+ const GRID_MIN_ITEM_WIDTH = 100;
959
+ const VIRTUAL_BUFFER_ROWS = 6;
824
960
 
825
961
  let eventHandlers = [];
826
962
  let searchDebounceTimer = null;
@@ -846,6 +982,42 @@
846
982
  element.removeEventListener(event, handler, options);
847
983
  });
848
984
  eventHandlers = [];
985
+ if (virtualRenderFrame) {
986
+ cancelAnimationFrame(virtualRenderFrame);
987
+ virtualRenderFrame = null;
988
+ }
989
+ if (directoryRequestController) {
990
+ directoryRequestController.abort();
991
+ directoryRequestController = null;
992
+ }
993
+ if (fileGridResizeObserver) {
994
+ fileGridResizeObserver.disconnect();
995
+ fileGridResizeObserver = null;
996
+ }
997
+ }
998
+
999
+ function syncEditModeUI() {
1000
+ document.body.classList.toggle('editing', editMode);
1001
+
1002
+ if (!editMode) {
1003
+ selectedPaths.clear();
1004
+ hideContextMenu();
1005
+ currentContextMenuFile = null;
1006
+ }
1007
+
1008
+ updateSelectionToolbar();
1009
+ }
1010
+
1011
+ async function loadExplorerConfig() {
1012
+ try {
1013
+ const response = await fetch('/__api/config');
1014
+ const config = await response.json();
1015
+ editMode = Boolean(config.editMode);
1016
+ } catch (error) {
1017
+ console.warn('Failed to load file explorer configuration:', error);
1018
+ editMode = false;
1019
+ }
1020
+ syncEditModeUI();
849
1021
  }
850
1022
 
851
1023
  function handleFileGridClick(e) {
@@ -866,6 +1038,15 @@
866
1038
  case 'download':
867
1039
  downloadFile(path, filename);
868
1040
  break;
1041
+ case 'rename':
1042
+ renamePath(path, filename);
1043
+ break;
1044
+ case 'delete':
1045
+ deletePath(path, filename);
1046
+ break;
1047
+ case 'toggleSelect':
1048
+ toggleSelectedPath(path, actionBtn.checked, actionBtn.closest('.file-item'));
1049
+ break;
869
1050
  }
870
1051
  return;
871
1052
  }
@@ -896,6 +1077,11 @@
896
1077
  hideContextMenu();
897
1078
  }
898
1079
 
1080
+ function handleFileGridScroll() {
1081
+ hideContextMenu();
1082
+ scheduleVirtualRender();
1083
+ }
1084
+
899
1085
  function handleModalClick(e) {
900
1086
  if (e.target === e.currentTarget) {
901
1087
  closeModal();
@@ -939,19 +1125,28 @@
939
1125
  }
940
1126
  }
941
1127
 
942
- document.addEventListener('DOMContentLoaded', () => {
943
- loadDirectory('/');
944
-
1128
+ document.addEventListener('DOMContentLoaded', async () => {
945
1129
  const fileGrid = document.getElementById('fileGrid');
946
1130
  const previewModal = document.getElementById('previewModal');
947
1131
 
948
1132
  addEventListenerWithCleanup(fileGrid, 'click', handleFileGridClick);
949
1133
  addEventListenerWithCleanup(fileGrid, 'contextmenu', handleFileGridContextmenu);
1134
+ addEventListenerWithCleanup(fileGrid, 'scroll', handleFileGridScroll, { passive: true });
950
1135
  addEventListenerWithCleanup(document, 'click', handleDocumentClick);
951
1136
  addEventListenerWithCleanup(document, 'scroll', handleDocumentScroll);
952
1137
  addEventListenerWithCleanup(previewModal, 'click', handleModalClick);
953
1138
  addEventListenerWithCleanup(previewModal, 'wheel', handleModalWheel, { passive: false });
954
1139
  addEventListenerWithCleanup(document, 'keydown', handleDocumentKeydown);
1140
+
1141
+ fileGridResizeObserver = new ResizeObserver(() => {
1142
+ if (virtualState && currentView === 'grid') {
1143
+ renderFiles(currentFiles, false);
1144
+ }
1145
+ });
1146
+ fileGridResizeObserver.observe(fileGrid);
1147
+
1148
+ await loadExplorerConfig();
1149
+ loadDirectory('/');
955
1150
  });
956
1151
 
957
1152
  window.addEventListener('beforeunload', cleanupEventListeners);
@@ -1007,60 +1202,60 @@
1007
1202
  downloadFile(path, path.split('/').pop());
1008
1203
  }
1009
1204
  break;
1205
+ case 'rename':
1206
+ renamePath(path, path.split('/').pop());
1207
+ break;
1208
+ case 'delete':
1209
+ deletePath(path, path.split('/').pop());
1210
+ break;
1010
1211
  }
1011
1212
 
1012
1213
  hideContextMenu();
1013
1214
  }
1014
1215
 
1015
1216
  async function loadDirectory(path) {
1016
- // console.log('Loading directory:', path);
1017
1217
  const fileGrid = document.getElementById('fileGrid');
1018
-
1019
- // 保存旧路径,以便失败时可以回退
1020
1218
  const oldCurrentPath = currentPath;
1219
+ const requestId = ++directoryRequestId;
1220
+
1221
+ if (directoryRequestController) {
1222
+ directoryRequestController.abort();
1223
+ }
1224
+ directoryRequestController = new AbortController();
1021
1225
 
1022
- // 重置所有状态
1023
1226
  allFiles = [];
1024
1227
  currentFiles = [];
1228
+ selectedPaths.clear();
1229
+ updateSelectionToolbar();
1025
1230
 
1026
- // 显示加载状态
1027
1231
  fileGrid.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
1028
1232
 
1029
1233
  try {
1030
- const response = await fetch(`/__api/list?path=${encodeURIComponent(path)}`);
1031
- // console.log('Response status:', response.status);
1032
-
1234
+ const response = await fetch(`/__api/list?path=${encodeURIComponent(path)}`, {
1235
+ signal: directoryRequestController.signal
1236
+ });
1033
1237
  const data = await response.json();
1034
- // console.log('API response data:', data);
1035
1238
 
1036
- if (data.error) {
1239
+ if (!response.ok || data.error) {
1037
1240
  throw new Error(data.error);
1038
1241
  }
1242
+ if (requestId !== directoryRequestId) return;
1039
1243
 
1040
1244
  currentPath = data.currentPath;
1041
1245
  allFiles = data.files || [];
1042
1246
 
1043
- // console.log('allFiles set to:', allFiles);
1044
-
1045
- renderBreadcrumb(data.currentPath, data.parentPath);
1046
-
1047
- // 确保在调用 processAndRenderFiles 之前 allFiles 已经设置好
1048
- setTimeout(() => {
1049
- processAndRenderFiles();
1050
- }, 10);
1051
-
1052
- updateBackButton(data.parentPath);
1053
-
1054
- // 清空搜索框
1055
1247
  if (document.getElementById('searchInput')) {
1056
1248
  document.getElementById('searchInput').value = '';
1057
1249
  }
1058
1250
  searchQuery = '';
1251
+
1252
+ renderBreadcrumb(data.currentPath, data.parentPath);
1253
+ processAndRenderFiles();
1254
+ updateBackButton(data.parentPath);
1059
1255
  } catch (error) {
1256
+ if (error.name === 'AbortError' || requestId !== directoryRequestId) return;
1060
1257
  console.error('Error loading directory:', error);
1061
- // 回退到旧路径
1062
1258
  currentPath = oldCurrentPath;
1063
- // 计算旧路径的父路径
1064
1259
  const parentPath = currentPath === '/' ? null : currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
1065
1260
  updateBackButton(parentPath);
1066
1261
  fileGrid.innerHTML = `<div class="empty-state"><p>错误: ${error.message}</p></div>`;
@@ -1209,6 +1404,77 @@
1209
1404
  }
1210
1405
  }
1211
1406
 
1407
+ async function requestJson(url, options) {
1408
+ const response = await fetch(url, options);
1409
+ const data = await response.json();
1410
+ if (!response.ok || data.error) {
1411
+ throw new Error(data.error || '请求失败');
1412
+ }
1413
+ return data;
1414
+ }
1415
+
1416
+ async function createEntry(type) {
1417
+ const label = type === 'directory' ? '文件夹' : '文件';
1418
+ const name = window.prompt(`请输入${label}名称`);
1419
+ if (name === null) return;
1420
+
1421
+ const trimmedName = name.trim();
1422
+ if (!trimmedName) {
1423
+ alert(`${label}名称不能为空`);
1424
+ return;
1425
+ }
1426
+
1427
+ try {
1428
+ await requestJson('/__api/path', {
1429
+ method: 'POST',
1430
+ headers: {
1431
+ 'Content-Type': 'application/json'
1432
+ },
1433
+ body: JSON.stringify({
1434
+ parentPath: currentPath,
1435
+ name: trimmedName,
1436
+ type
1437
+ })
1438
+ });
1439
+
1440
+ showToast(`新建${label}成功`);
1441
+ await loadDirectory(currentPath);
1442
+ } catch (error) {
1443
+ console.error(`新建${label}失败:`, error);
1444
+ alert(`新建${label}失败: ` + error.message);
1445
+ }
1446
+ }
1447
+
1448
+ async function renamePath(filePath, fileName) {
1449
+ const nextName = window.prompt('请输入新名称', fileName || filePath.split('/').pop());
1450
+ if (nextName === null) return;
1451
+
1452
+ const trimmedName = nextName.trim();
1453
+ if (!trimmedName) {
1454
+ alert('名称不能为空');
1455
+ return;
1456
+ }
1457
+
1458
+ try {
1459
+ await requestJson('/__api/path', {
1460
+ method: 'PATCH',
1461
+ headers: {
1462
+ 'Content-Type': 'application/json'
1463
+ },
1464
+ body: JSON.stringify({
1465
+ path: filePath,
1466
+ name: trimmedName
1467
+ })
1468
+ });
1469
+
1470
+ showToast('重命名成功');
1471
+ await loadDirectory(currentPath);
1472
+ } catch (error) {
1473
+ console.error('重命名失败:', error);
1474
+ alert('重命名失败: ' + error.message);
1475
+ }
1476
+ }
1477
+
1212
1478
  function downloadFile(filePath, fileName) {
1213
1479
  const url = `/__api/file?path=${encodeURIComponent(filePath)}`;
1214
1480
  const a = document.createElement('a');
@@ -1219,32 +1485,119 @@
1219
1485
  document.body.removeChild(a);
1220
1486
  }
1221
1487
 
1222
- function renderBreadcrumb(currentPath, parentPath) {
1223
- const breadcrumb = document.getElementById('breadcrumb');
1224
- const parts = currentPath.split('/').filter(Boolean);
1488
+ async function deletePath(filePath, fileName) {
1489
+ const label = fileName || filePath.split('/').pop() || filePath;
1490
+ const confirmed = window.confirm(`确定要删除 "${label}" 吗?此操作不可恢复。`);
1491
+
1492
+ if (!confirmed) return;
1493
+
1494
+ try {
1495
+ const response = await fetch('/__api/path', {
1496
+ method: 'DELETE',
1497
+ headers: {
1498
+ 'Content-Type': 'application/json'
1499
+ },
1500
+ body: JSON.stringify({ path: filePath })
1501
+ });
1502
+
1503
+ const data = await response.json();
1504
+ if (!response.ok || !data.success) {
1505
+ throw new Error(data.error || '删除失败');
1506
+ }
1507
+
1508
+ closeModal();
1509
+ showToast('删除成功');
1510
+ await loadDirectory(currentPath);
1511
+ } catch (error) {
1512
+ console.error('删除失败:', error);
1513
+ alert('删除失败: ' + error.message);
1514
+ }
1515
+ }
1516
+
1517
+ async function deleteSelectedPaths() {
1518
+ const paths = Array.from(selectedPaths);
1519
+ if (paths.length === 0) return;
1520
+
1521
+ const confirmed = window.confirm(`确定要删除选中的 ${paths.length} 项吗?此操作不可恢复。`);
1522
+ if (!confirmed) return;
1523
+
1524
+ try {
1525
+ await requestJson('/__api/paths', {
1526
+ method: 'DELETE',
1527
+ headers: {
1528
+ 'Content-Type': 'application/json'
1529
+ },
1530
+ body: JSON.stringify({ paths })
1531
+ });
1225
1532
 
1226
- let html = '<div class="breadcrumb-item" onclick="loadDirectory(\'/\')">根目录</div>';
1533
+ selectedPaths.clear();
1534
+ updateSelectionToolbar();
1535
+ showToast('批量删除成功');
1536
+ await loadDirectory(currentPath);
1537
+ } catch (error) {
1538
+ console.error('批量删除失败:', error);
1539
+ alert('批量删除失败: ' + error.message);
1540
+ await loadDirectory(currentPath);
1541
+ }
1542
+ }
1227
1543
 
1544
+ function toggleSelectedPath(filePath, checked, fileItem) {
1545
+ if (checked) {
1546
+ selectedPaths.add(filePath);
1547
+ } else {
1548
+ selectedPaths.delete(filePath);
1549
+ }
1550
+
1551
+ if (fileItem) {
1552
+ fileItem.classList.toggle('selected', checked);
1553
+ }
1554
+
1555
+ updateSelectionToolbar();
1556
+ }
1557
+
1558
+ function updateSelectionToolbar() {
1559
+ const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
1560
+ if (!deleteSelectedBtn) return;
1561
+
1562
+ const count = selectedPaths.size;
1563
+ deleteSelectedBtn.disabled = count === 0;
1564
+ deleteSelectedBtn.textContent = count ? `删除选中(${count})` : '删除选中';
1565
+ }
1566
+
1567
+ function renderBreadcrumb(currentPath) {
1568
+ const breadcrumb = document.getElementById('breadcrumb');
1569
+ const parts = currentPath.split('/').filter(Boolean);
1570
+ const fragment = document.createDocumentFragment();
1571
+ const root = document.createElement('div');
1572
+ root.className = 'breadcrumb-item';
1573
+ root.textContent = '根目录';
1574
+ root.addEventListener('click', () => loadDirectory('/'));
1575
+ fragment.appendChild(root);
1228
1576
  let current = '';
1229
1577
  parts.forEach((part, index) => {
1230
1578
  current += '/' + part;
1231
1579
  const isLast = index === parts.length - 1;
1232
- html += '<span class="breadcrumb-separator">/</span>';
1580
+ const separator = document.createElement('span');
1581
+ separator.className = 'breadcrumb-separator';
1582
+ separator.textContent = '/';
1583
+ fragment.appendChild(separator);
1584
+ const item = document.createElement('div');
1585
+ item.className = `breadcrumb-item${isLast ? ' current' : ''}`;
1586
+ item.textContent = part;
1233
1587
  if (isLast) {
1234
- html += `<div class="breadcrumb-item current">${escapeHtml(part)}</div>`;
1235
1588
  } else {
1236
- html += `<div class="breadcrumb-item" onclick="loadDirectory('${current}')">${escapeHtml(part)}</div>`;
1589
+ const targetPath = current;
1590
+ item.addEventListener('click', () => loadDirectory(targetPath));
1237
1591
  }
1592
+ fragment.appendChild(item);
1238
1593
  });
1239
-
1240
- breadcrumb.innerHTML = html;
1594
+ breadcrumb.replaceChildren(fragment);
1241
1595
  }
1242
1596
 
1243
- function renderFiles(files) {
1597
+ function renderFiles(files, resetScroll = true) {
1244
1598
  const fileGrid = document.getElementById('fileGrid');
1245
1599
  const toggleBirthtimeBtn = document.getElementById('toggleBirthtimeBtn');
1246
1600
 
1247
- // 确保视图类名正确
1248
1601
  if (currentView === 'list') {
1249
1602
  fileGrid.classList.add('list-view');
1250
1603
  toggleBirthtimeBtn.style.display = 'inline-block';
@@ -1254,67 +1607,99 @@
1254
1607
  }
1255
1608
 
1256
1609
  if (!files || files.length === 0) {
1610
+ virtualState = null;
1257
1611
  fileGrid.innerHTML = '<div class="empty-state"><p>该目录为空</p></div>';
1258
1612
  return;
1259
1613
  }
1260
1614
 
1261
- const fragment = document.createDocumentFragment();
1615
+ const isListView = currentView === 'list';
1616
+ const columnCount = isListView
1617
+ ? 1
1618
+ : Math.max(1, Math.floor((Math.max(fileGrid.clientWidth, GRID_MIN_ITEM_WIDTH + 16) - 8) / (GRID_MIN_ITEM_WIDTH + 8)));
1619
+ const rowHeight = isListView ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT;
1620
+ const totalRows = Math.ceil(files.length / columnCount);
1621
+ const spacer = document.createElement('div');
1622
+ spacer.className = 'virtual-spacer';
1623
+ spacer.style.height = `${totalRows * rowHeight + (isListView ? 0 : 8)}px`;
1624
+ const content = document.createElement('div');
1625
+ content.className = `virtual-content ${isListView ? 'list-view' : 'grid-view'}`;
1626
+ if (!isListView) {
1627
+ content.style.gridTemplateColumns = `repeat(${columnCount}, minmax(${GRID_MIN_ITEM_WIDTH}px, 1fr))`;
1628
+ }
1262
1629
 
1263
- files.forEach(file => {
1264
- const icon = getFileIcon(file);
1265
- const size = formatSize(file.size);
1266
- const mtime = formatDate(file.mtime);
1267
- const birthtime = formatDate(file.birthtime);
1268
- const hiddenClass = file.isHidden ? 'hidden-file' : '';
1269
-
1270
- const fileItem = document.createElement('div');
1271
- fileItem.className = `file-item ${hiddenClass}`;
1272
- fileItem.dataset.path = file.path;
1273
- fileItem.dataset.isDirectory = file.isDirectory;
1274
-
1275
- if (currentView === 'list') {
1276
- fileItem.innerHTML = `
1277
- <div class="file-icon">${icon}</div>
1278
- <div class="file-info">
1279
- <div class="file-name">${escapeHtml(file.name)}</div>
1280
- <div class="file-meta">
1281
- ${file.isDirectory ? '<span>目录</span>' : `<span>${size}</span>`}
1282
- <span>${mtime}</span>
1283
- <span class="birthtime" style="display: ${showBirthtime ? 'inline-block' : 'none'};">${birthtime}</span>
1284
- </div>
1285
- </div>
1286
- <div class="action-buttons">
1287
- <button class="copy-btn" data-action="copyPath" data-path="${file.path}" title="复制路径">📋</button>
1288
- <button class="action-btn open" data-action="openInExplorer" data-path="${
1289
- file.path
1290
- }" title="在文件管理器中打开">📂</button>
1291
- ${
1292
- !file.isDirectory
1293
- ? `<button class="action-btn download" data-action="download" data-path="${
1294
- file.path
1295
- }" data-filename="${escapeHtml(file.name)}" title="下载">⬇️</button>`
1296
- : ''
1297
- }
1298
- </div>
1299
- `;
1300
- } else {
1301
- fileItem.innerHTML = `
1302
- <div class="file-icon">${icon}</div>
1303
- <div class="file-info">
1304
- <div class="file-name">${escapeHtml(file.name)}</div>
1305
- <div class="file-meta">
1306
- ${file.isDirectory ? '目录' : size}
1307
- </div>
1308
- </div>
1309
- `;
1310
- }
1630
+ virtualState = { files, columnCount, rowHeight, isListView, spacer, content, startRow: -1, endRow: -1 };
1631
+ fileGrid.replaceChildren(spacer, content);
1632
+ if (resetScroll) fileGrid.scrollTop = 0;
1633
+ renderVirtualWindow();
1634
+ }
1311
1635
 
1312
- fragment.appendChild(fileItem);
1636
+ function scheduleVirtualRender() {
1637
+ if (virtualRenderFrame || !virtualState) return;
1638
+ virtualRenderFrame = requestAnimationFrame(() => {
1639
+ virtualRenderFrame = null;
1640
+ renderVirtualWindow();
1313
1641
  });
1642
+ }
1643
+
1644
+ function renderVirtualWindow() {
1645
+ if (!virtualState) return;
1646
+ const fileGrid = document.getElementById('fileGrid');
1647
+ const { files, columnCount, rowHeight, isListView, content } = virtualState;
1648
+ const firstVisibleRow = Math.floor(fileGrid.scrollTop / rowHeight);
1649
+ const visibleRows = Math.ceil(fileGrid.clientHeight / rowHeight);
1650
+ const startRow = Math.max(0, firstVisibleRow - VIRTUAL_BUFFER_ROWS);
1651
+ const endRow = Math.min(Math.ceil(files.length / columnCount), firstVisibleRow + visibleRows + VIRTUAL_BUFFER_ROWS);
1652
+ const startIndex = startRow * columnCount;
1653
+ const endIndex = Math.min(files.length, endRow * columnCount);
1654
+
1655
+ if (virtualState.startRow === startRow && virtualState.endRow === endRow) return;
1656
+ virtualState.startRow = startRow;
1657
+ virtualState.endRow = endRow;
1658
+ const fragment = document.createDocumentFragment();
1314
1659
 
1315
- // 清空并一次性更新 DOM
1316
- fileGrid.innerHTML = '';
1317
- fileGrid.appendChild(fragment);
1660
+ for (let index = startIndex; index < endIndex; index++) {
1661
+ fragment.appendChild(createFileItem(files[index], isListView));
1662
+ }
1663
+
1664
+ content.style.transform = `translateY(${startRow * rowHeight}px)`;
1665
+ content.replaceChildren(fragment);
1666
+ }
1667
+
1668
+ function createFileItem(file, isListView) {
1669
+ const icon = getFileIcon(file);
1670
+ const size = formatSize(file.size);
1671
+ const mtime = formatDate(file.mtime);
1672
+ const birthtime = formatDate(file.birthtime);
1673
+ const hiddenClass = file.isHidden ? 'hidden-file' : '';
1674
+ const selectedClass = selectedPaths.has(file.path) ? 'selected' : '';
1675
+ const selectedAttr = selectedPaths.has(file.path) ? 'checked' : '';
1676
+ const selectLabel = file.isDirectory ? '选择文件夹' : '选择文件';
1677
+ const pathAttr = escapeAttribute(file.path);
1678
+ const nameAttr = escapeAttribute(file.name);
1679
+ const fileItem = document.createElement('div');
1680
+ fileItem.className = `file-item ${hiddenClass} ${selectedClass}`;
1681
+ fileItem.dataset.path = file.path;
1682
+ fileItem.dataset.isDirectory = file.isDirectory;
1683
+
1684
+ if (isListView) {
1685
+ fileItem.innerHTML = `
1686
+ <input class="file-select edit-only edit-inline-block" type="checkbox" data-action="toggleSelect" data-path="${pathAttr}" title="${selectLabel}" ${selectedAttr} />
1687
+ <div class="file-icon">${icon}</div>
1688
+ <div class="file-info"><div class="file-name">${escapeHtml(file.name)}</div><div class="file-meta">${file.isDirectory ? '<span>目录</span>' : `<span>${size}</span>`}<span>${mtime}</span><span class="birthtime" style="display: ${showBirthtime ? 'inline-block' : 'none'};">${birthtime}</span></div></div>
1689
+ <div class="action-buttons">
1690
+ <button class="copy-btn" data-action="copyPath" data-path="${pathAttr}" title="复制路径">📋</button>
1691
+ <button class="action-btn open" data-action="openInExplorer" data-path="${pathAttr}" title="在文件管理器中打开">📂</button>
1692
+ ${!file.isDirectory ? `<button class="action-btn download" data-action="download" data-path="${pathAttr}" data-filename="${nameAttr}" title="下载">⬇️</button>` : ''}
1693
+ <button class="action-btn rename edit-only edit-inline" data-action="rename" data-path="${pathAttr}" data-filename="${nameAttr}" title="重命名">✏️</button>
1694
+ <button class="action-btn delete edit-only edit-inline" data-action="delete" data-path="${pathAttr}" data-filename="${nameAttr}" title="删除">🗑️</button>
1695
+ </div>`;
1696
+ } else {
1697
+ fileItem.innerHTML = `
1698
+ <input class="file-select edit-only edit-inline-block" type="checkbox" data-action="toggleSelect" data-path="${pathAttr}" title="${selectLabel}" ${selectedAttr} />
1699
+ <div class="file-icon">${icon}</div>
1700
+ <div class="file-info"><div class="file-name">${escapeHtml(file.name)}</div><div class="file-meta">${file.isDirectory ? '目录' : size}</div></div>`;
1701
+ }
1702
+ return fileItem;
1318
1703
  }
1319
1704
 
1320
1705
  function handleItemClick(path, isDirectory) {
@@ -1565,7 +1950,7 @@
1565
1950
  }
1566
1951
 
1567
1952
  function renderBinaryPreview(blob, filename) {
1568
- const url = URL.createObjectURL(blob);
1953
+ currentPreviewObjectUrl = URL.createObjectURL(blob);
1569
1954
  return `
1570
1955
  <div class="preview-container">
1571
1956
  <div class="binary-info">
@@ -1575,7 +1960,7 @@
1575
1960
  </div>
1576
1961
  <div class="empty-state">
1577
1962
  <p>该文件类型暂不支持预览</p>
1578
- <p><a href="${url}" download="${filename}">点击下载</a></p>
1963
+ <p><a href="${currentPreviewObjectUrl}" download="${escapeAttribute(filename)}">点击下载</a></p>
1579
1964
  </div>
1580
1965
  </div>
1581
1966
  `;
@@ -1587,6 +1972,7 @@
1587
1972
  const body = document.getElementById('previewBody');
1588
1973
 
1589
1974
  title.textContent = path.split('/').pop();
1975
+ releasePreviewObjectUrl();
1590
1976
  body.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
1591
1977
  modal.classList.add('active');
1592
1978
 
@@ -1752,6 +2138,14 @@
1752
2138
 
1753
2139
  function closeModal() {
1754
2140
  document.getElementById('previewModal').classList.remove('active');
2141
+ releasePreviewObjectUrl();
2142
+ }
2143
+
2144
+ function releasePreviewObjectUrl() {
2145
+ if (currentPreviewObjectUrl) {
2146
+ URL.revokeObjectURL(currentPreviewObjectUrl);
2147
+ currentPreviewObjectUrl = null;
2148
+ }
1755
2149
  }
1756
2150
 
1757
2151
  function goBack() {
@@ -1895,6 +2289,16 @@
1895
2289
  div.textContent = text;
1896
2290
  return div.innerHTML;
1897
2291
  }
2292
+
2293
+ function escapeAttribute(value) {
2294
+ return String(value).replace(/[&<>"']/g, char => ({
2295
+ '&': '&amp;',
2296
+ '<': '&lt;',
2297
+ '>': '&gt;',
2298
+ '"': '&quot;',
2299
+ "'": '&#39;'
2300
+ })[char]);
2301
+ }
1898
2302
  </script>
1899
2303
  </body>
1900
2304
  </html>