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 +12 -0
- package/dist/file-explorer.html +226 -199
- package/dist/runtime.js +10 -10
- package/package.json +1 -1
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。
|
package/dist/file-explorer.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
359
|
+
grid-auto-rows: 124px;
|
|
360
|
+
padding: 8px;
|
|
392
361
|
}
|
|
393
362
|
|
|
394
|
-
.
|
|
395
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
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
|
|
1027
|
-
|
|
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
|
-
|
|
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
|
|
1567
|
+
function renderBreadcrumb(currentPath) {
|
|
1573
1568
|
const breadcrumb = document.getElementById('breadcrumb');
|
|
1574
1569
|
const parts = currentPath.split('/').filter(Boolean);
|
|
1575
|
-
|
|
1576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
-
|
|
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="${
|
|
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
|
+
'&': '&',
|
|
2296
|
+
'<': '<',
|
|
2297
|
+
'>': '>',
|
|
2298
|
+
'"': '"',
|
|
2299
|
+
"'": '''
|
|
2300
|
+
})[char]);
|
|
2301
|
+
}
|
|
2275
2302
|
</script>
|
|
2276
2303
|
</body>
|
|
2277
2304
|
</html>
|