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.
- package/README.md +12 -0
- package/dist/file-explorer.html +505 -101
- package/dist/runtime.js +13 -13
- package/package.json +5 -3
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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
359
|
+
grid-auto-rows: 124px;
|
|
360
|
+
padding: 8px;
|
|
298
361
|
}
|
|
299
362
|
|
|
300
|
-
.
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
1223
|
-
const
|
|
1224
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
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="${
|
|
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
|
+
'&': '&',
|
|
2296
|
+
'<': '<',
|
|
2297
|
+
'>': '>',
|
|
2298
|
+
'"': '"',
|
|
2299
|
+
"'": '''
|
|
2300
|
+
})[char]);
|
|
2301
|
+
}
|
|
1898
2302
|
</script>
|
|
1899
2303
|
</body>
|
|
1900
2304
|
</html>
|