mock-service-cli 4.2.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.
package/README.md CHANGED
@@ -74,6 +74,8 @@ npx mock-service-cli [options] [path]
74
74
  | `-R` 或 `--static-server` | 启用静态服务器,指定静态资源目录 | - |
75
75
  | `-w` 或 `--open` | 自动打开 API 概览页、文件浏览器页 | false |
76
76
  | `-e` 或 `--explorer` | 启用文件浏览器服务器,指定要浏览的目录 | ./ |
77
+ | `--edit` | 启用文件浏览器的新建、重命名和删除操作 | false |
78
+ | `--host` | 将全部 Web 服务暴露到所有 IPv4 网卡 | false |
77
79
 
78
80
  ## 📖 使用示例
79
81
 
@@ -131,8 +133,18 @@ mock-service-cli -e /path/to/directory
131
133
 
132
134
  # 指定端口
133
135
  mock-service-cli -e ./ -p 9090
136
+
137
+ # 启用文件编辑操作
138
+ mock-service-cli -e ./ --edit
139
+
140
+ # 暴露到局域网(编辑操作仍需额外传入 --edit)
141
+ mock-service-cli -e ./ --host
134
142
  ```
135
143
 
144
+ 默认情况下,Mock、SPA、Static 和 File Explorer 服务只监听 `127.0.0.1`。
145
+ `--host` 会使这些服务监听所有 IPv4 网卡,请仅在可信网络中使用。文件浏览器默认只读;
146
+ 必须在启动时传入 `--edit` 才能创建、重命名或删除文件。
147
+
136
148
  ## 📝 编写 Mock 文件
137
149
 
138
150
  Mock 文件支持 CommonJS 规范,不支持 ES Module。
@@ -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 {
@@ -180,56 +181,6 @@
180
181
  cursor: not-allowed;
181
182
  }
182
183
 
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
184
  body:not(.editing) .edit-only {
234
185
  display: none !important;
235
186
  }
@@ -384,18 +335,33 @@
384
335
  }
385
336
 
386
337
  .file-grid {
387
- 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 {
388
357
  display: grid;
389
- grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
390
358
  gap: 8px;
391
- overflow: auto;
359
+ grid-auto-rows: 124px;
360
+ padding: 8px;
392
361
  }
393
362
 
394
- .file-grid.list-view {
395
- grid-template-columns: 1fr;
396
- gap: 0;
397
- padding: 4px 0;
398
- flex: unset;
363
+ .virtual-content.list-view {
364
+ display: block;
399
365
  }
400
366
 
401
367
  .file-item {
@@ -408,6 +374,10 @@
408
374
  transition: all 0.2s;
409
375
  }
410
376
 
377
+ .grid-view .file-item {
378
+ height: 124px;
379
+ }
380
+
411
381
  .file-item:hover {
412
382
  background: #f8f9fa;
413
383
  }
@@ -479,6 +449,13 @@
479
449
  line-height: 1.3;
480
450
  }
481
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
+
482
459
  .list-view .file-name {
483
460
  margin-bottom: 0;
484
461
  flex: 1;
@@ -866,11 +843,6 @@
866
843
  返回上级
867
844
  </button>
868
845
  <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
846
  <button
875
847
  class="toolbar-action-btn edit-only edit-inline"
876
848
  onclick="createEntry('directory')"
@@ -967,6 +939,7 @@
967
939
  let showHiddenFiles = true;
968
940
  let showBirthtime = false;
969
941
  let currentPreviewContent = '';
942
+ let currentPreviewObjectUrl = null;
970
943
  let currentZoom = 1;
971
944
  let currentRotation = 0;
972
945
  let currentContextMenuFile = null;
@@ -974,6 +947,16 @@
974
947
  let imageFiles = [];
975
948
  let selectedPaths = new Set();
976
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;
977
960
 
978
961
  let eventHandlers = [];
979
962
  let searchDebounceTimer = null;
@@ -999,20 +982,22 @@
999
982
  element.removeEventListener(event, handler, options);
1000
983
  });
1001
984
  eventHandlers = [];
1002
- }
1003
-
1004
- function initializeEditMode() {
1005
- editMode = false;
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
+ }
1006
997
  }
1007
998
 
1008
999
  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
- }
1000
+ document.body.classList.toggle('editing', editMode);
1016
1001
 
1017
1002
  if (!editMode) {
1018
1003
  selectedPaths.clear();
@@ -1023,10 +1008,16 @@
1023
1008
  updateSelectionToolbar();
1024
1009
  }
1025
1010
 
1026
- function toggleEditMode(enabled) {
1027
- editMode = Boolean(enabled);
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
+ }
1028
1020
  syncEditModeUI();
1029
- processAndRenderFiles();
1030
1021
  }
1031
1022
 
1032
1023
  function handleFileGridClick(e) {
@@ -1086,6 +1077,11 @@
1086
1077
  hideContextMenu();
1087
1078
  }
1088
1079
 
1080
+ function handleFileGridScroll() {
1081
+ hideContextMenu();
1082
+ scheduleVirtualRender();
1083
+ }
1084
+
1089
1085
  function handleModalClick(e) {
1090
1086
  if (e.target === e.currentTarget) {
1091
1087
  closeModal();
@@ -1129,21 +1125,28 @@
1129
1125
  }
1130
1126
  }
1131
1127
 
1132
- document.addEventListener('DOMContentLoaded', () => {
1133
- initializeEditMode();
1134
- syncEditModeUI();
1135
- loadDirectory('/');
1136
-
1128
+ document.addEventListener('DOMContentLoaded', async () => {
1137
1129
  const fileGrid = document.getElementById('fileGrid');
1138
1130
  const previewModal = document.getElementById('previewModal');
1139
1131
 
1140
1132
  addEventListenerWithCleanup(fileGrid, 'click', handleFileGridClick);
1141
1133
  addEventListenerWithCleanup(fileGrid, 'contextmenu', handleFileGridContextmenu);
1134
+ addEventListenerWithCleanup(fileGrid, 'scroll', handleFileGridScroll, { passive: true });
1142
1135
  addEventListenerWithCleanup(document, 'click', handleDocumentClick);
1143
1136
  addEventListenerWithCleanup(document, 'scroll', handleDocumentScroll);
1144
1137
  addEventListenerWithCleanup(previewModal, 'click', handleModalClick);
1145
1138
  addEventListenerWithCleanup(previewModal, 'wheel', handleModalWheel, { passive: false });
1146
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('/');
1147
1150
  });
1148
1151
 
1149
1152
  window.addEventListener('beforeunload', cleanupEventListeners);
@@ -1211,56 +1214,48 @@
1211
1214
  }
1212
1215
 
1213
1216
  async function loadDirectory(path) {
1214
- // console.log('Loading directory:', path);
1215
1217
  const fileGrid = document.getElementById('fileGrid');
1216
-
1217
- // 保存旧路径,以便失败时可以回退
1218
1218
  const oldCurrentPath = currentPath;
1219
+ const requestId = ++directoryRequestId;
1220
+
1221
+ if (directoryRequestController) {
1222
+ directoryRequestController.abort();
1223
+ }
1224
+ directoryRequestController = new AbortController();
1219
1225
 
1220
- // 重置所有状态
1221
1226
  allFiles = [];
1222
1227
  currentFiles = [];
1223
1228
  selectedPaths.clear();
1224
1229
  updateSelectionToolbar();
1225
1230
 
1226
- // 显示加载状态
1227
1231
  fileGrid.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
1228
1232
 
1229
1233
  try {
1230
- const response = await fetch(`/__api/list?path=${encodeURIComponent(path)}`);
1231
- // console.log('Response status:', response.status);
1232
-
1234
+ const response = await fetch(`/__api/list?path=${encodeURIComponent(path)}`, {
1235
+ signal: directoryRequestController.signal
1236
+ });
1233
1237
  const data = await response.json();
1234
- // console.log('API response data:', data);
1235
1238
 
1236
- if (data.error) {
1239
+ if (!response.ok || data.error) {
1237
1240
  throw new Error(data.error);
1238
1241
  }
1242
+ if (requestId !== directoryRequestId) return;
1239
1243
 
1240
1244
  currentPath = data.currentPath;
1241
1245
  allFiles = data.files || [];
1242
1246
 
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
1247
  if (document.getElementById('searchInput')) {
1256
1248
  document.getElementById('searchInput').value = '';
1257
1249
  }
1258
1250
  searchQuery = '';
1251
+
1252
+ renderBreadcrumb(data.currentPath, data.parentPath);
1253
+ processAndRenderFiles();
1254
+ updateBackButton(data.parentPath);
1259
1255
  } catch (error) {
1256
+ if (error.name === 'AbortError' || requestId !== directoryRequestId) return;
1260
1257
  console.error('Error loading directory:', error);
1261
- // 回退到旧路径
1262
1258
  currentPath = oldCurrentPath;
1263
- // 计算旧路径的父路径
1264
1259
  const parentPath = currentPath === '/' ? null : currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
1265
1260
  updateBackButton(parentPath);
1266
1261
  fileGrid.innerHTML = `<div class="empty-state"><p>错误: ${error.message}</p></div>`;
@@ -1569,32 +1564,40 @@
1569
1564
  deleteSelectedBtn.textContent = count ? `删除选中(${count})` : '删除选中';
1570
1565
  }
1571
1566
 
1572
- function renderBreadcrumb(currentPath, parentPath) {
1567
+ function renderBreadcrumb(currentPath) {
1573
1568
  const breadcrumb = document.getElementById('breadcrumb');
1574
1569
  const parts = currentPath.split('/').filter(Boolean);
1575
-
1576
- let html = '<div class="breadcrumb-item" onclick="loadDirectory(\'/\')">根目录</div>';
1577
-
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);
1578
1576
  let current = '';
1579
1577
  parts.forEach((part, index) => {
1580
1578
  current += '/' + part;
1581
1579
  const isLast = index === parts.length - 1;
1582
- 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;
1583
1587
  if (isLast) {
1584
- html += `<div class="breadcrumb-item current">${escapeHtml(part)}</div>`;
1585
1588
  } else {
1586
- html += `<div class="breadcrumb-item" onclick="loadDirectory('${current}')">${escapeHtml(part)}</div>`;
1589
+ const targetPath = current;
1590
+ item.addEventListener('click', () => loadDirectory(targetPath));
1587
1591
  }
1592
+ fragment.appendChild(item);
1588
1593
  });
1589
-
1590
- breadcrumb.innerHTML = html;
1594
+ breadcrumb.replaceChildren(fragment);
1591
1595
  }
1592
1596
 
1593
- function renderFiles(files) {
1597
+ function renderFiles(files, resetScroll = true) {
1594
1598
  const fileGrid = document.getElementById('fileGrid');
1595
1599
  const toggleBirthtimeBtn = document.getElementById('toggleBirthtimeBtn');
1596
1600
 
1597
- // 确保视图类名正确
1598
1601
  if (currentView === 'list') {
1599
1602
  fileGrid.classList.add('list-view');
1600
1603
  toggleBirthtimeBtn.style.display = 'inline-block';
@@ -1604,94 +1607,99 @@
1604
1607
  }
1605
1608
 
1606
1609
  if (!files || files.length === 0) {
1610
+ virtualState = null;
1607
1611
  fileGrid.innerHTML = '<div class="empty-state"><p>该目录为空</p></div>';
1608
1612
  return;
1609
1613
  }
1610
1614
 
1611
- 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
+ }
1612
1629
 
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
- }
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
+ }
1688
1635
 
1689
- fragment.appendChild(fileItem);
1636
+ function scheduleVirtualRender() {
1637
+ if (virtualRenderFrame || !virtualState) return;
1638
+ virtualRenderFrame = requestAnimationFrame(() => {
1639
+ virtualRenderFrame = null;
1640
+ renderVirtualWindow();
1690
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();
1659
+
1660
+ for (let index = startIndex; index < endIndex; index++) {
1661
+ fragment.appendChild(createFileItem(files[index], isListView));
1662
+ }
1691
1663
 
1692
- // 清空并一次性更新 DOM
1693
- fileGrid.innerHTML = '';
1694
- fileGrid.appendChild(fragment);
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;
1695
1703
  }
1696
1704
 
1697
1705
  function handleItemClick(path, isDirectory) {
@@ -1942,7 +1950,7 @@
1942
1950
  }
1943
1951
 
1944
1952
  function renderBinaryPreview(blob, filename) {
1945
- const url = URL.createObjectURL(blob);
1953
+ currentPreviewObjectUrl = URL.createObjectURL(blob);
1946
1954
  return `
1947
1955
  <div class="preview-container">
1948
1956
  <div class="binary-info">
@@ -1952,7 +1960,7 @@
1952
1960
  </div>
1953
1961
  <div class="empty-state">
1954
1962
  <p>该文件类型暂不支持预览</p>
1955
- <p><a href="${url}" download="${filename}">点击下载</a></p>
1963
+ <p><a href="${currentPreviewObjectUrl}" download="${escapeAttribute(filename)}">点击下载</a></p>
1956
1964
  </div>
1957
1965
  </div>
1958
1966
  `;
@@ -1964,6 +1972,7 @@
1964
1972
  const body = document.getElementById('previewBody');
1965
1973
 
1966
1974
  title.textContent = path.split('/').pop();
1975
+ releasePreviewObjectUrl();
1967
1976
  body.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
1968
1977
  modal.classList.add('active');
1969
1978
 
@@ -2129,6 +2138,14 @@
2129
2138
 
2130
2139
  function closeModal() {
2131
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
+ }
2132
2149
  }
2133
2150
 
2134
2151
  function goBack() {
@@ -2272,6 +2289,16 @@
2272
2289
  div.textContent = text;
2273
2290
  return div.innerHTML;
2274
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
+ }
2275
2302
  </script>
2276
2303
  </body>
2277
2304
  </html>