mock-service-cli 4.2.0 → 4.3.1

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.
@@ -11,6 +11,13 @@
11
11
  box-sizing: border-box;
12
12
  }
13
13
 
14
+ .icon-sprite {
15
+ position: absolute;
16
+ width: 0;
17
+ height: 0;
18
+ overflow: hidden;
19
+ }
20
+
14
21
  body {
15
22
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
16
23
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@@ -83,6 +90,7 @@
83
90
  flex: 1;
84
91
  display: flex;
85
92
  flex-direction: column;
93
+ min-height: 0;
86
94
  }
87
95
 
88
96
  .toolbar {
@@ -180,56 +188,6 @@
180
188
  cursor: not-allowed;
181
189
  }
182
190
 
183
- .edit-mode-toggle {
184
- display: inline-flex;
185
- align-items: center;
186
- gap: 8px;
187
- font-size: 12px;
188
- color: #666;
189
- user-select: none;
190
- white-space: nowrap;
191
- }
192
-
193
- .edit-mode-toggle input {
194
- position: absolute;
195
- width: 1px;
196
- height: 1px;
197
- opacity: 0;
198
- pointer-events: none;
199
- }
200
-
201
- .edit-mode-switch {
202
- width: 38px;
203
- height: 20px;
204
- background: #d1d5db;
205
- border-radius: 999px;
206
- position: relative;
207
- transition: background 0.2s;
208
- flex-shrink: 0;
209
- cursor: pointer;
210
- }
211
-
212
- .edit-mode-switch::after {
213
- content: '';
214
- position: absolute;
215
- width: 16px;
216
- height: 16px;
217
- border-radius: 50%;
218
- top: 2px;
219
- left: 2px;
220
- background: #fff;
221
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
222
- transition: transform 0.2s;
223
- }
224
-
225
- .edit-mode-toggle input:checked + .edit-mode-switch {
226
- background: #667eea;
227
- }
228
-
229
- .edit-mode-toggle input:checked + .edit-mode-switch::after {
230
- transform: translateX(18px);
231
- }
232
-
233
191
  body:not(.editing) .edit-only {
234
192
  display: none !important;
235
193
  }
@@ -384,18 +342,36 @@
384
342
  }
385
343
 
386
344
  .file-grid {
387
- padding: 8px;
388
- display: grid;
389
- grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
390
- gap: 8px;
345
+ --list-row-height: 37px;
346
+ position: relative;
391
347
  overflow: auto;
348
+ flex: 1;
349
+ min-height: 0;
392
350
  }
393
351
 
394
- .file-grid.list-view {
395
- grid-template-columns: 1fr;
396
- gap: 0;
397
- padding: 4px 0;
398
- flex: unset;
352
+ .virtual-spacer {
353
+ width: 1px;
354
+ pointer-events: none;
355
+ }
356
+
357
+ .virtual-content {
358
+ position: absolute;
359
+ top: 0;
360
+ left: 0;
361
+ right: 0;
362
+ overflow-anchor: none;
363
+ }
364
+
365
+ .virtual-content.grid-view {
366
+ display: block;
367
+ }
368
+
369
+ .virtual-content.list-view {
370
+ display: block;
371
+ }
372
+
373
+ .virtual-content .file-item {
374
+ position: absolute;
399
375
  }
400
376
 
401
377
  .file-item {
@@ -405,7 +381,11 @@
405
381
  padding: 10px 8px;
406
382
  border-radius: 6px;
407
383
  cursor: pointer;
408
- transition: all 0.2s;
384
+ transition: background-color 0.2s;
385
+ }
386
+
387
+ .grid-view .file-item {
388
+ height: 124px;
409
389
  }
410
390
 
411
391
  .file-item:hover {
@@ -423,7 +403,7 @@
423
403
  border-bottom: 1px solid #f0f0f0;
424
404
  border-radius: 0;
425
405
  min-height: auto;
426
- height: 37px;
406
+ height: var(--list-row-height);
427
407
  }
428
408
 
429
409
  .file-icon {
@@ -479,10 +459,18 @@
479
459
  line-height: 1.3;
480
460
  }
481
461
 
462
+ .grid-view .file-name {
463
+ display: -webkit-box;
464
+ -webkit-box-orient: vertical;
465
+ -webkit-line-clamp: 2;
466
+ overflow: hidden;
467
+ }
468
+
482
469
  .list-view .file-name {
483
470
  margin-bottom: 0;
484
471
  flex: 1;
485
472
  font-size: 12px;
473
+ min-width: 0;
486
474
  }
487
475
 
488
476
  .file-meta {
@@ -491,6 +479,11 @@
491
479
  }
492
480
  .birthtime {
493
481
  padding-right: 4px;
482
+ display: none;
483
+ }
484
+
485
+ .birthtime.visible {
486
+ display: inline-block;
494
487
  }
495
488
 
496
489
  .list-view .file-meta {
@@ -499,6 +492,11 @@
499
492
  font-size: 10px;
500
493
  }
501
494
 
495
+ .date-compact,
496
+ .more-actions-btn {
497
+ display: none;
498
+ }
499
+
502
500
  .hidden-file {
503
501
  opacity: 0.6;
504
502
  }
@@ -509,7 +507,50 @@
509
507
  }
510
508
 
511
509
  .list-view .action-buttons {
512
- display: flex;
510
+ display: none;
511
+ }
512
+
513
+ .list-view .more-actions-btn {
514
+ display: inline-flex;
515
+ flex: 0 0 32px;
516
+ align-items: center;
517
+ justify-content: center;
518
+ align-self: center;
519
+ width: 32px;
520
+ height: 28px;
521
+ margin-left: 8px;
522
+ padding: 0;
523
+ border: 1px solid transparent;
524
+ border-radius: 5px;
525
+ background: #f1f3f5;
526
+ color: #555;
527
+ font-size: 20px;
528
+ line-height: 1;
529
+ cursor: pointer;
530
+ touch-action: manipulation;
531
+ -webkit-tap-highlight-color: transparent;
532
+ transition:
533
+ background-color 120ms ease,
534
+ border-color 120ms ease,
535
+ color 120ms ease,
536
+ transform 120ms ease;
537
+ }
538
+
539
+ .list-view .more-actions-btn:hover {
540
+ border-color: #d9dce1;
541
+ background: #e5e7eb;
542
+ color: #222;
543
+ transform: scale(1.06);
544
+ }
545
+
546
+ .list-view .more-actions-btn:active {
547
+ background: #d9dce1;
548
+ transform: scale(0.92);
549
+ }
550
+
551
+ .list-view .more-actions-btn:focus-visible {
552
+ outline: 2px solid #667eea;
553
+ outline-offset: 1px;
513
554
  }
514
555
 
515
556
  .action-btn {
@@ -546,6 +587,90 @@
546
587
  color: white;
547
588
  }
548
589
 
590
+ @media (max-width: 767px) {
591
+ .file-grid.list-view {
592
+ --list-row-height: 48px;
593
+ }
594
+
595
+ .file-grid.list-view.show-birthtime {
596
+ --list-row-height: 65px;
597
+ }
598
+
599
+ .list-view .file-item {
600
+ align-items: flex-start;
601
+ padding: 7px 10px;
602
+ }
603
+
604
+ .list-view .file-icon {
605
+ flex: 0 0 20px;
606
+ margin-top: 2px;
607
+ margin-right: 7px;
608
+ }
609
+
610
+ .list-view .file-select {
611
+ flex: 0 0 auto;
612
+ margin: 3px 7px 0 0;
613
+ }
614
+
615
+ .list-view .file-info {
616
+ display: flex;
617
+ flex: 1;
618
+ flex-direction: column;
619
+ align-items: stretch;
620
+ gap: 3px;
621
+ min-width: 0;
622
+ }
623
+
624
+ .list-view .file-name {
625
+ width: 100%;
626
+ overflow: hidden;
627
+ text-overflow: ellipsis;
628
+ white-space: nowrap;
629
+ }
630
+
631
+ .list-view .file-meta {
632
+ display: flex;
633
+ width: 100%;
634
+ gap: 3px;
635
+ flex-wrap: wrap;
636
+ overflow: hidden;
637
+ white-space: nowrap;
638
+ }
639
+
640
+ .list-view .file-meta > span {
641
+ overflow: hidden;
642
+ text-overflow: ellipsis;
643
+ }
644
+
645
+ .date-full {
646
+ display: none;
647
+ }
648
+
649
+ .date-compact {
650
+ display: inline;
651
+ vertical-align: middle;
652
+ }
653
+
654
+ .list-view .birthtime.visible {
655
+ flex-basis: 100%;
656
+ display: block;
657
+ padding-right: 0;
658
+ }
659
+
660
+ .list-view .action-buttons {
661
+ display: none !important;
662
+ }
663
+
664
+ .list-view .more-actions-btn {
665
+ align-self: flex-start;
666
+ margin: 10px 0 0 6px;
667
+ }
668
+
669
+ .file-grid.list-view.show-birthtime .more-actions-btn {
670
+ margin-top: 17px;
671
+ }
672
+ }
673
+
549
674
  .modal {
550
675
  display: none;
551
676
  position: fixed;
@@ -851,6 +976,15 @@
851
976
  </style>
852
977
  </head>
853
978
  <body>
979
+ <svg class="icon-sprite" aria-hidden="true" focusable="false">
980
+ <symbol id="icon-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
981
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
982
+ </symbol>
983
+ <symbol id="icon-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
984
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
985
+ <polyline points="14 2 14 8 20 8"></polyline>
986
+ </symbol>
987
+ </svg>
854
988
  <div class="container">
855
989
  <div class="header">
856
990
  <h1>📁 文件浏览器</h1>
@@ -866,11 +1000,6 @@
866
1000
  返回上级
867
1001
  </button>
868
1002
  <button class="refresh-btn" onclick="loadDirectory(currentPath)" title="刷新">🔄</button>
869
- <label class="edit-mode-toggle" title="启用编辑模式">
870
- <input id="editModeToggle" type="checkbox" onchange="toggleEditMode(this.checked)" />
871
- <span class="edit-mode-switch"></span>
872
- <span class="edit-mode-label">编辑</span>
873
- </label>
874
1003
  <button
875
1004
  class="toolbar-action-btn edit-only edit-inline"
876
1005
  onclick="createEntry('directory')"
@@ -967,6 +1096,7 @@
967
1096
  let showHiddenFiles = true;
968
1097
  let showBirthtime = false;
969
1098
  let currentPreviewContent = '';
1099
+ let currentPreviewObjectUrl = null;
970
1100
  let currentZoom = 1;
971
1101
  let currentRotation = 0;
972
1102
  let currentContextMenuFile = null;
@@ -974,6 +1104,16 @@
974
1104
  let imageFiles = [];
975
1105
  let selectedPaths = new Set();
976
1106
  let editMode = false;
1107
+ let directoryRequestController = null;
1108
+ let directoryRequestId = 0;
1109
+ let virtualRenderFrame = null;
1110
+ let virtualState = null;
1111
+ let fileGridResizeObserver = null;
1112
+
1113
+ const LIST_ROW_HEIGHT = 37;
1114
+ const GRID_ROW_HEIGHT = 132;
1115
+ const GRID_MIN_ITEM_WIDTH = 100;
1116
+ const VIRTUAL_BUFFER_ROWS = 6;
977
1117
 
978
1118
  let eventHandlers = [];
979
1119
  let searchDebounceTimer = null;
@@ -999,20 +1139,22 @@
999
1139
  element.removeEventListener(event, handler, options);
1000
1140
  });
1001
1141
  eventHandlers = [];
1002
- }
1003
-
1004
- function initializeEditMode() {
1005
- editMode = false;
1142
+ if (virtualRenderFrame) {
1143
+ cancelAnimationFrame(virtualRenderFrame);
1144
+ virtualRenderFrame = null;
1145
+ }
1146
+ if (directoryRequestController) {
1147
+ directoryRequestController.abort();
1148
+ directoryRequestController = null;
1149
+ }
1150
+ if (fileGridResizeObserver) {
1151
+ fileGridResizeObserver.disconnect();
1152
+ fileGridResizeObserver = null;
1153
+ }
1006
1154
  }
1007
1155
 
1008
1156
  function syncEditModeUI() {
1009
- const body = document.body;
1010
- const toggle = document.getElementById('editModeToggle');
1011
-
1012
- body.classList.toggle('editing', editMode);
1013
- if (toggle) {
1014
- toggle.checked = editMode;
1015
- }
1157
+ document.body.classList.toggle('editing', editMode);
1016
1158
 
1017
1159
  if (!editMode) {
1018
1160
  selectedPaths.clear();
@@ -1023,10 +1165,16 @@
1023
1165
  updateSelectionToolbar();
1024
1166
  }
1025
1167
 
1026
- function toggleEditMode(enabled) {
1027
- editMode = Boolean(enabled);
1168
+ async function loadExplorerConfig() {
1169
+ try {
1170
+ const response = await fetch('/__api/config');
1171
+ const config = await response.json();
1172
+ editMode = Boolean(config.editMode);
1173
+ } catch (error) {
1174
+ console.warn('Failed to load file explorer configuration:', error);
1175
+ editMode = false;
1176
+ }
1028
1177
  syncEditModeUI();
1029
- processAndRenderFiles();
1030
1178
  }
1031
1179
 
1032
1180
  function handleFileGridClick(e) {
@@ -1056,6 +1204,12 @@
1056
1204
  case 'toggleSelect':
1057
1205
  toggleSelectedPath(path, actionBtn.checked, actionBtn.closest('.file-item'));
1058
1206
  break;
1207
+ case 'more': {
1208
+ const fileItem = actionBtn.closest('.file-item');
1209
+ const rect = actionBtn.getBoundingClientRect();
1210
+ showContextMenu(rect.right, rect.bottom, fileItem.dataset.path, fileItem.dataset.isDirectory === 'true');
1211
+ break;
1212
+ }
1059
1213
  }
1060
1214
  return;
1061
1215
  }
@@ -1086,6 +1240,11 @@
1086
1240
  hideContextMenu();
1087
1241
  }
1088
1242
 
1243
+ function handleFileGridScroll() {
1244
+ hideContextMenu();
1245
+ scheduleVirtualRender();
1246
+ }
1247
+
1089
1248
  function handleModalClick(e) {
1090
1249
  if (e.target === e.currentTarget) {
1091
1250
  closeModal();
@@ -1129,21 +1288,28 @@
1129
1288
  }
1130
1289
  }
1131
1290
 
1132
- document.addEventListener('DOMContentLoaded', () => {
1133
- initializeEditMode();
1134
- syncEditModeUI();
1135
- loadDirectory('/');
1136
-
1291
+ document.addEventListener('DOMContentLoaded', async () => {
1137
1292
  const fileGrid = document.getElementById('fileGrid');
1138
1293
  const previewModal = document.getElementById('previewModal');
1139
1294
 
1140
1295
  addEventListenerWithCleanup(fileGrid, 'click', handleFileGridClick);
1141
1296
  addEventListenerWithCleanup(fileGrid, 'contextmenu', handleFileGridContextmenu);
1297
+ addEventListenerWithCleanup(fileGrid, 'scroll', handleFileGridScroll, { passive: true });
1142
1298
  addEventListenerWithCleanup(document, 'click', handleDocumentClick);
1143
1299
  addEventListenerWithCleanup(document, 'scroll', handleDocumentScroll);
1144
1300
  addEventListenerWithCleanup(previewModal, 'click', handleModalClick);
1145
1301
  addEventListenerWithCleanup(previewModal, 'wheel', handleModalWheel, { passive: false });
1146
1302
  addEventListenerWithCleanup(document, 'keydown', handleDocumentKeydown);
1303
+
1304
+ fileGridResizeObserver = new ResizeObserver(() => {
1305
+ if (virtualState) {
1306
+ renderFiles(currentFiles, false);
1307
+ }
1308
+ });
1309
+ fileGridResizeObserver.observe(fileGrid);
1310
+
1311
+ await loadExplorerConfig();
1312
+ loadDirectory('/');
1147
1313
  });
1148
1314
 
1149
1315
  window.addEventListener('beforeunload', cleanupEventListeners);
@@ -1211,56 +1377,48 @@
1211
1377
  }
1212
1378
 
1213
1379
  async function loadDirectory(path) {
1214
- // console.log('Loading directory:', path);
1215
1380
  const fileGrid = document.getElementById('fileGrid');
1216
-
1217
- // 保存旧路径,以便失败时可以回退
1218
1381
  const oldCurrentPath = currentPath;
1382
+ const requestId = ++directoryRequestId;
1383
+
1384
+ if (directoryRequestController) {
1385
+ directoryRequestController.abort();
1386
+ }
1387
+ directoryRequestController = new AbortController();
1219
1388
 
1220
- // 重置所有状态
1221
1389
  allFiles = [];
1222
1390
  currentFiles = [];
1223
1391
  selectedPaths.clear();
1224
1392
  updateSelectionToolbar();
1225
1393
 
1226
- // 显示加载状态
1227
1394
  fileGrid.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
1228
1395
 
1229
1396
  try {
1230
- const response = await fetch(`/__api/list?path=${encodeURIComponent(path)}`);
1231
- // console.log('Response status:', response.status);
1232
-
1397
+ const response = await fetch(`/__api/list?path=${encodeURIComponent(path)}`, {
1398
+ signal: directoryRequestController.signal
1399
+ });
1233
1400
  const data = await response.json();
1234
- // console.log('API response data:', data);
1235
1401
 
1236
- if (data.error) {
1402
+ if (!response.ok || data.error) {
1237
1403
  throw new Error(data.error);
1238
1404
  }
1405
+ if (requestId !== directoryRequestId) return;
1239
1406
 
1240
1407
  currentPath = data.currentPath;
1241
1408
  allFiles = data.files || [];
1242
1409
 
1243
- // console.log('allFiles set to:', allFiles);
1244
-
1245
- renderBreadcrumb(data.currentPath, data.parentPath);
1246
-
1247
- // 确保在调用 processAndRenderFiles 之前 allFiles 已经设置好
1248
- setTimeout(() => {
1249
- processAndRenderFiles();
1250
- }, 10);
1251
-
1252
- updateBackButton(data.parentPath);
1253
-
1254
- // 清空搜索框
1255
1410
  if (document.getElementById('searchInput')) {
1256
1411
  document.getElementById('searchInput').value = '';
1257
1412
  }
1258
1413
  searchQuery = '';
1414
+
1415
+ renderBreadcrumb(data.currentPath, data.parentPath);
1416
+ processAndRenderFiles();
1417
+ updateBackButton(data.parentPath);
1259
1418
  } catch (error) {
1419
+ if (error.name === 'AbortError' || requestId !== directoryRequestId) return;
1260
1420
  console.error('Error loading directory:', error);
1261
- // 回退到旧路径
1262
1421
  currentPath = oldCurrentPath;
1263
- // 计算旧路径的父路径
1264
1422
  const parentPath = currentPath === '/' ? null : currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
1265
1423
  updateBackButton(parentPath);
1266
1424
  fileGrid.innerHTML = `<div class="empty-state"><p>错误: ${error.message}</p></div>`;
@@ -1569,129 +1727,286 @@
1569
1727
  deleteSelectedBtn.textContent = count ? `删除选中(${count})` : '删除选中';
1570
1728
  }
1571
1729
 
1572
- function renderBreadcrumb(currentPath, parentPath) {
1730
+ function renderBreadcrumb(currentPath) {
1573
1731
  const breadcrumb = document.getElementById('breadcrumb');
1574
1732
  const parts = currentPath.split('/').filter(Boolean);
1575
-
1576
- let html = '<div class="breadcrumb-item" onclick="loadDirectory(\'/\')">根目录</div>';
1577
-
1733
+ const fragment = document.createDocumentFragment();
1734
+ const root = document.createElement('div');
1735
+ root.className = 'breadcrumb-item';
1736
+ root.textContent = '根目录';
1737
+ root.addEventListener('click', () => loadDirectory('/'));
1738
+ fragment.appendChild(root);
1578
1739
  let current = '';
1579
1740
  parts.forEach((part, index) => {
1580
1741
  current += '/' + part;
1581
1742
  const isLast = index === parts.length - 1;
1582
- html += '<span class="breadcrumb-separator">/</span>';
1743
+ const separator = document.createElement('span');
1744
+ separator.className = 'breadcrumb-separator';
1745
+ separator.textContent = '/';
1746
+ fragment.appendChild(separator);
1747
+ const item = document.createElement('div');
1748
+ item.className = `breadcrumb-item${isLast ? ' current' : ''}`;
1749
+ item.textContent = part;
1583
1750
  if (isLast) {
1584
- html += `<div class="breadcrumb-item current">${escapeHtml(part)}</div>`;
1585
1751
  } else {
1586
- html += `<div class="breadcrumb-item" onclick="loadDirectory('${current}')">${escapeHtml(part)}</div>`;
1752
+ const targetPath = current;
1753
+ item.addEventListener('click', () => loadDirectory(targetPath));
1587
1754
  }
1755
+ fragment.appendChild(item);
1588
1756
  });
1589
-
1590
- breadcrumb.innerHTML = html;
1757
+ breadcrumb.replaceChildren(fragment);
1591
1758
  }
1592
1759
 
1593
- function renderFiles(files) {
1760
+ function renderFiles(files, resetScroll = true) {
1594
1761
  const fileGrid = document.getElementById('fileGrid');
1595
1762
  const toggleBirthtimeBtn = document.getElementById('toggleBirthtimeBtn');
1596
1763
 
1597
- // 确保视图类名正确
1598
1764
  if (currentView === 'list') {
1599
1765
  fileGrid.classList.add('list-view');
1766
+ fileGrid.classList.toggle('show-birthtime', showBirthtime);
1600
1767
  toggleBirthtimeBtn.style.display = 'inline-block';
1601
1768
  } else {
1602
1769
  toggleBirthtimeBtn.style.display = 'none';
1603
1770
  fileGrid.classList.remove('list-view');
1771
+ fileGrid.classList.remove('show-birthtime');
1604
1772
  }
1605
1773
 
1606
1774
  if (!files || files.length === 0) {
1775
+ virtualState = null;
1607
1776
  fileGrid.innerHTML = '<div class="empty-state"><p>该目录为空</p></div>';
1608
1777
  return;
1609
1778
  }
1610
1779
 
1611
- const fragment = document.createDocumentFragment();
1780
+ const isListView = currentView === 'list';
1781
+ const columnCount = isListView
1782
+ ? 1
1783
+ : Math.max(
1784
+ 1,
1785
+ Math.floor((Math.max(fileGrid.clientWidth, GRID_MIN_ITEM_WIDTH + 16) - 8) / (GRID_MIN_ITEM_WIDTH + 8))
1786
+ );
1787
+ const rowHeight = isListView ? getListRowHeight(fileGrid) : GRID_ROW_HEIGHT;
1788
+ const totalRows = Math.ceil(files.length / columnCount);
1789
+ const gridItemWidth = isListView ? null : (fileGrid.clientWidth - 16 - (columnCount - 1) * 8) / columnCount;
1790
+ const spacer = document.createElement('div');
1791
+ spacer.className = 'virtual-spacer';
1792
+ spacer.style.height = `${totalRows * rowHeight + (isListView ? 0 : 8)}px`;
1793
+ const content = document.createElement('div');
1794
+ content.className = `virtual-content ${isListView ? 'list-view' : 'grid-view'}`;
1795
+
1796
+ virtualState = {
1797
+ files,
1798
+ columnCount,
1799
+ rowHeight,
1800
+ gridItemWidth,
1801
+ isListView,
1802
+ spacer,
1803
+ content,
1804
+ itemPool: [],
1805
+ freeItems: [],
1806
+ renderedItems: new Map(),
1807
+ startRow: -1,
1808
+ endRow: -1
1809
+ };
1810
+ fileGrid.replaceChildren(spacer, content);
1811
+ if (resetScroll) fileGrid.scrollTop = 0;
1812
+ renderVirtualWindow();
1813
+ }
1612
1814
 
1613
- files.forEach(file => {
1614
- const icon = getFileIcon(file);
1615
- const size = formatSize(file.size);
1616
- const mtime = formatDate(file.mtime);
1617
- const birthtime = formatDate(file.birthtime);
1618
- const hiddenClass = file.isHidden ? 'hidden-file' : '';
1619
- const selectedClass = selectedPaths.has(file.path) ? 'selected' : '';
1620
- const selectedAttr = selectedPaths.has(file.path) ? 'checked' : '';
1621
- const selectLabel = file.isDirectory ? '选择文件夹' : '选择文件';
1622
-
1623
- const fileItem = document.createElement('div');
1624
- fileItem.className = `file-item ${hiddenClass} ${selectedClass}`;
1625
- fileItem.dataset.path = file.path;
1626
- fileItem.dataset.isDirectory = file.isDirectory;
1627
-
1628
- if (currentView === 'list') {
1629
- fileItem.innerHTML = `
1630
- <input
1631
- class="file-select edit-only edit-inline-block"
1632
- type="checkbox"
1633
- data-action="toggleSelect"
1634
- data-path="${file.path}"
1635
- data-filename="${escapeHtml(file.name)}"
1636
- title="${selectLabel}"
1637
- ${selectedAttr}
1638
- />
1639
- <div class="file-icon">${icon}</div>
1640
- <div class="file-info">
1641
- <div class="file-name">${escapeHtml(file.name)}</div>
1642
- <div class="file-meta">
1643
- ${file.isDirectory ? '<span>目录</span>' : `<span>${size}</span>`}
1644
- <span>${mtime}</span>
1645
- <span class="birthtime" style="display: ${showBirthtime ? 'inline-block' : 'none'};">${birthtime}</span>
1646
- </div>
1647
- </div>
1648
- <div class="action-buttons">
1649
- <button class="copy-btn" data-action="copyPath" data-path="${file.path}" title="复制路径">📋</button>
1650
- <button class="action-btn open" data-action="openInExplorer" data-path="${
1651
- file.path
1652
- }" title="在文件管理器中打开">📂</button>
1653
- ${
1654
- !file.isDirectory
1655
- ? `<button class="action-btn download" data-action="download" data-path="${
1656
- file.path
1657
- }" data-filename="${escapeHtml(file.name)}" title="下载">⬇️</button>`
1658
- : ''
1659
- }
1660
- <button class="action-btn rename edit-only edit-inline" data-action="rename" data-path="${file.path}" data-filename="${escapeHtml(
1661
- file.name
1662
- )}" title="重命名">✏️</button>
1663
- <button class="action-btn delete edit-only edit-inline" data-action="delete" data-path="${file.path}" data-filename="${escapeHtml(
1664
- file.name
1665
- )}" title="删除">🗑️</button>
1666
- </div>
1667
- `;
1668
- } else {
1669
- fileItem.innerHTML = `
1670
- <input
1671
- class="file-select edit-only edit-inline-block"
1672
- type="checkbox"
1673
- data-action="toggleSelect"
1674
- data-path="${file.path}"
1675
- data-filename="${escapeHtml(file.name)}"
1676
- title="${selectLabel}"
1677
- ${selectedAttr}
1678
- />
1679
- <div class="file-icon">${icon}</div>
1680
- <div class="file-info">
1681
- <div class="file-name">${escapeHtml(file.name)}</div>
1682
- <div class="file-meta">
1683
- ${file.isDirectory ? '目录' : size}
1684
- </div>
1685
- </div>
1686
- `;
1687
- }
1815
+ function scheduleVirtualRender() {
1816
+ if (virtualRenderFrame || !virtualState) return;
1817
+ virtualRenderFrame = requestAnimationFrame(() => {
1818
+ virtualRenderFrame = null;
1819
+ renderVirtualWindow();
1820
+ });
1821
+ }
1688
1822
 
1689
- fragment.appendChild(fileItem);
1823
+ function getListRowHeight(fileGrid) {
1824
+ const rowHeight = Number.parseFloat(getComputedStyle(fileGrid).getPropertyValue('--list-row-height'));
1825
+ return Number.isFinite(rowHeight) ? rowHeight : LIST_ROW_HEIGHT;
1826
+ }
1827
+
1828
+ function renderVirtualWindow() {
1829
+ if (!virtualState) return;
1830
+ const fileGrid = document.getElementById('fileGrid');
1831
+ const {
1832
+ files,
1833
+ columnCount,
1834
+ rowHeight,
1835
+ gridItemWidth,
1836
+ isListView,
1837
+ content,
1838
+ itemPool,
1839
+ freeItems,
1840
+ renderedItems
1841
+ } = virtualState;
1842
+ const firstVisibleRow = Math.floor(fileGrid.scrollTop / rowHeight);
1843
+ const visibleRows = Math.ceil(fileGrid.clientHeight / rowHeight);
1844
+ const startRow = Math.max(0, firstVisibleRow - VIRTUAL_BUFFER_ROWS);
1845
+ const endRow = Math.min(
1846
+ Math.ceil(files.length / columnCount),
1847
+ firstVisibleRow + visibleRows + VIRTUAL_BUFFER_ROWS
1848
+ );
1849
+ const startIndex = startRow * columnCount;
1850
+ const endIndex = Math.min(files.length, endRow * columnCount);
1851
+
1852
+ if (virtualState.startRow === startRow && virtualState.endRow === endRow) return;
1853
+ virtualState.startRow = startRow;
1854
+ virtualState.endRow = endRow;
1855
+ renderedItems.forEach((fileItem, fileIndex) => {
1856
+ if (fileIndex < startIndex || fileIndex >= endIndex) {
1857
+ renderedItems.delete(fileIndex);
1858
+ fileItem.hidden = true;
1859
+ freeItems.push(fileItem);
1860
+ }
1690
1861
  });
1691
1862
 
1692
- // 清空并一次性更新 DOM
1693
- fileGrid.innerHTML = '';
1694
- fileGrid.appendChild(fragment);
1863
+ for (let fileIndex = startIndex; fileIndex < endIndex; fileIndex++) {
1864
+ if (renderedItems.has(fileIndex)) continue;
1865
+
1866
+ let fileItem = freeItems.pop();
1867
+ if (!fileItem) {
1868
+ fileItem = createFileItemElement(isListView);
1869
+ itemPool.push(fileItem);
1870
+ content.appendChild(fileItem);
1871
+ }
1872
+ updateFileItem(fileItem, files[fileIndex]);
1873
+ positionVirtualItem(fileItem, fileIndex, columnCount, rowHeight, gridItemWidth, isListView);
1874
+ fileItem.hidden = false;
1875
+ renderedItems.set(fileIndex, fileItem);
1876
+ }
1877
+ }
1878
+
1879
+ function positionVirtualItem(fileItem, fileIndex, columnCount, rowHeight, gridItemWidth, isListView) {
1880
+ fileItem.style.transform = '';
1881
+ if (isListView) {
1882
+ fileItem.style.left = '0';
1883
+ fileItem.style.right = '0';
1884
+ fileItem.style.top = `${fileIndex * rowHeight}px`;
1885
+ fileItem.style.width = '';
1886
+ return;
1887
+ }
1888
+
1889
+ const row = Math.floor(fileIndex / columnCount);
1890
+ const column = fileIndex % columnCount;
1891
+ fileItem.style.left = `${8 + column * (gridItemWidth + 8)}px`;
1892
+ fileItem.style.right = 'auto';
1893
+ fileItem.style.top = `${8 + row * rowHeight}px`;
1894
+ fileItem.style.width = `${gridItemWidth}px`;
1895
+ }
1896
+
1897
+ function createFileItemElement(isListView) {
1898
+ const fileItem = document.createElement('div');
1899
+ fileItem.className = 'file-item';
1900
+ const select = document.createElement('input');
1901
+ select.className = 'file-select edit-only edit-inline-block';
1902
+ select.type = 'checkbox';
1903
+ select.dataset.action = 'toggleSelect';
1904
+ fileItem.appendChild(select);
1905
+
1906
+ const icon = createFileIconElement();
1907
+ fileItem.appendChild(icon.wrapper);
1908
+
1909
+ const info = document.createElement('div');
1910
+ info.className = 'file-info';
1911
+ const name = document.createElement('div');
1912
+ name.className = 'file-name';
1913
+ info.appendChild(name);
1914
+ const meta = document.createElement('div');
1915
+ meta.className = 'file-meta';
1916
+ const type = document.createElement('span');
1917
+ meta.appendChild(type);
1918
+ info.appendChild(meta);
1919
+ fileItem.appendChild(info);
1920
+
1921
+ const refs = { select, icon, name, type };
1922
+
1923
+ if (isListView) {
1924
+ const mtime = createDateMeta();
1925
+ const birthtime = createDateMeta('birthtime');
1926
+ meta.append(mtime.container, birthtime.container);
1927
+ const more = createActionButton('more-actions-btn', 'more', '更多操作', '⋮');
1928
+ more.setAttribute('aria-label', '更多操作');
1929
+ fileItem.appendChild(more);
1930
+ Object.assign(refs, { mtime, birthtime });
1931
+ }
1932
+
1933
+ fileItem._refs = refs;
1934
+ return fileItem;
1935
+ }
1936
+
1937
+ function createFileIconElement() {
1938
+ const namespace = 'http://www.w3.org/2000/svg';
1939
+ const wrapper = document.createElement('div');
1940
+ wrapper.className = 'file-icon';
1941
+ const svg = document.createElementNS(namespace, 'svg');
1942
+ svg.setAttribute('viewBox', '0 0 24 24');
1943
+ svg.setAttribute('aria-hidden', 'true');
1944
+ const use = document.createElementNS(namespace, 'use');
1945
+ svg.appendChild(use);
1946
+ wrapper.appendChild(svg);
1947
+ return { wrapper, svg, use };
1948
+ }
1949
+
1950
+ function createDateMeta(className = '') {
1951
+ const container = document.createElement('span');
1952
+ if (className) container.className = className;
1953
+ const full = document.createElement('span');
1954
+ full.className = 'date-full';
1955
+ const compact = document.createElement('span');
1956
+ compact.className = 'date-compact';
1957
+ container.append(full, compact);
1958
+ return { container, full, compact };
1959
+ }
1960
+
1961
+ function createActionButton(className, action, title, label) {
1962
+ const button = document.createElement('button');
1963
+ button.className = className;
1964
+ button.dataset.action = action;
1965
+ button.title = title;
1966
+ button.textContent = label;
1967
+ return button;
1968
+ }
1969
+
1970
+ function updateFileItem(fileItem, file) {
1971
+ const refs = fileItem._refs;
1972
+ const view = getFileView(file);
1973
+ const isSelected = selectedPaths.has(file.path);
1974
+ fileItem.className = `file-item${file.isHidden ? ' hidden-file' : ''}${isSelected ? ' selected' : ''}`;
1975
+ fileItem.dataset.path = file.path;
1976
+ fileItem.dataset.isDirectory = file.isDirectory;
1977
+ refs.select.dataset.path = file.path;
1978
+ refs.select.checked = isSelected;
1979
+ refs.select.title = file.isDirectory ? '选择文件夹' : '选择文件';
1980
+ updateFileIcon(refs.icon, view.iconInfo);
1981
+ refs.name.textContent = file.name;
1982
+ refs.type.textContent = view.typeLabel;
1983
+
1984
+ if (!refs.mtime) return;
1985
+
1986
+ refs.mtime.full.textContent = view.mtime;
1987
+ refs.mtime.compact.textContent = view.compactMtime;
1988
+ refs.birthtime.full.textContent = view.birthtime;
1989
+ refs.birthtime.compact.textContent = view.compactBirthtime;
1990
+ refs.birthtime.container.classList.toggle('visible', showBirthtime);
1991
+ }
1992
+
1993
+ function getFileView(file) {
1994
+ if (!file._view) {
1995
+ file._view = {
1996
+ typeLabel: file.isDirectory ? '目录' : formatSize(file.size),
1997
+ mtime: formatDate(file.mtime),
1998
+ compactMtime: formatDate(file.mtime, false),
1999
+ birthtime: formatDate(file.birthtime),
2000
+ compactBirthtime: `创建 ${formatDate(file.birthtime, false)}`,
2001
+ iconInfo: getFileIconInfo(file)
2002
+ };
2003
+ }
2004
+ return file._view;
2005
+ }
2006
+
2007
+ function updateFileIcon(icon, iconInfo) {
2008
+ icon.use.setAttribute('href', iconInfo.symbol);
2009
+ icon.svg.style.color = iconInfo.color;
1695
2010
  }
1696
2011
 
1697
2012
  function handleItemClick(path, isDirectory) {
@@ -1942,7 +2257,7 @@
1942
2257
  }
1943
2258
 
1944
2259
  function renderBinaryPreview(blob, filename) {
1945
- const url = URL.createObjectURL(blob);
2260
+ currentPreviewObjectUrl = URL.createObjectURL(blob);
1946
2261
  return `
1947
2262
  <div class="preview-container">
1948
2263
  <div class="binary-info">
@@ -1952,7 +2267,7 @@
1952
2267
  </div>
1953
2268
  <div class="empty-state">
1954
2269
  <p>该文件类型暂不支持预览</p>
1955
- <p><a href="${url}" download="${filename}">点击下载</a></p>
2270
+ <p><a href="${currentPreviewObjectUrl}" download="${escapeAttribute(filename)}">点击下载</a></p>
1956
2271
  </div>
1957
2272
  </div>
1958
2273
  `;
@@ -1964,6 +2279,7 @@
1964
2279
  const body = document.getElementById('previewBody');
1965
2280
 
1966
2281
  title.textContent = path.split('/').pop();
2282
+ releasePreviewObjectUrl();
1967
2283
  body.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
1968
2284
  modal.classList.add('active');
1969
2285
 
@@ -2129,6 +2445,14 @@
2129
2445
 
2130
2446
  function closeModal() {
2131
2447
  document.getElementById('previewModal').classList.remove('active');
2448
+ releasePreviewObjectUrl();
2449
+ }
2450
+
2451
+ function releasePreviewObjectUrl() {
2452
+ if (currentPreviewObjectUrl) {
2453
+ URL.revokeObjectURL(currentPreviewObjectUrl);
2454
+ currentPreviewObjectUrl = null;
2455
+ }
2132
2456
  }
2133
2457
 
2134
2458
  function goBack() {
@@ -2161,14 +2485,16 @@
2161
2485
  renderFiles(currentFiles);
2162
2486
  }
2163
2487
 
2164
- function getFileIcon(file) {
2488
+ const fileIconInfoCache = new Map();
2489
+
2490
+ function getFileIconInfo(file) {
2165
2491
  if (file.isDirectory) {
2166
- return `<svg viewBox="0 0 24 24" fill="none" stroke="#667eea" stroke-width="2">
2167
- <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
2168
- </svg>`;
2492
+ return { symbol: '#icon-folder', color: '#667eea' };
2169
2493
  }
2170
2494
 
2171
2495
  const ext = file.name.split('.').pop().toLowerCase();
2496
+ const cached = fileIconInfoCache.get(ext);
2497
+ if (cached) return cached;
2172
2498
  const iconColors = {
2173
2499
  js: '#f7df1e',
2174
2500
  json: '#292929',
@@ -2246,12 +2572,9 @@
2246
2572
  webm: '#2196f3'
2247
2573
  };
2248
2574
 
2249
- const color = iconColors[ext] || '#95a5a6';
2250
-
2251
- return `<svg viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2">
2252
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
2253
- <polyline points="14 2 14 8 20 8"></polyline>
2254
- </svg>`;
2575
+ const iconInfo = { symbol: '#icon-file', color: iconColors[ext] || '#95a5a6' };
2576
+ fileIconInfoCache.set(ext, iconInfo);
2577
+ return iconInfo;
2255
2578
  }
2256
2579
 
2257
2580
  function formatSize(bytes) {
@@ -2262,9 +2585,15 @@
2262
2585
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
2263
2586
  }
2264
2587
 
2265
- function formatDate(dateStr) {
2266
- const date = new Date(dateStr);
2267
- return date.toLocaleString('zh-CN');
2588
+ function formatDate(value, includeSeconds = true) {
2589
+ const date = new Date(value);
2590
+ const year = date.getFullYear();
2591
+ const month = String(date.getMonth() + 1).padStart(2, '0');
2592
+ const day = String(date.getDate()).padStart(2, '0');
2593
+ const hours = String(date.getHours()).padStart(2, '0');
2594
+ const minutes = String(date.getMinutes()).padStart(2, '0');
2595
+ const base = `${year}/${month}/${day} ${hours}:${minutes}`;
2596
+ return includeSeconds ? `${base}:${String(date.getSeconds()).padStart(2, '0')}` : base;
2268
2597
  }
2269
2598
 
2270
2599
  function escapeHtml(text) {
@@ -2272,6 +2601,20 @@
2272
2601
  div.textContent = text;
2273
2602
  return div.innerHTML;
2274
2603
  }
2604
+
2605
+ function escapeAttribute(value) {
2606
+ return String(value).replace(
2607
+ /[&<>"']/g,
2608
+ char =>
2609
+ ({
2610
+ '&': '&amp;',
2611
+ '<': '&lt;',
2612
+ '>': '&gt;',
2613
+ '"': '&quot;',
2614
+ "'": '&#39;'
2615
+ })[char]
2616
+ );
2617
+ }
2275
2618
  </script>
2276
2619
  </body>
2277
2620
  </html>