1011 lines
38 KiB
JavaScript
1011 lines
38 KiB
JavaScript
(function() {
|
|
'use strict';
|
|
|
|
const API_BASE = '/api';
|
|
let currentPath = '/';
|
|
let selectedFiles = new Set();
|
|
let eventSource = null;
|
|
|
|
const elements = {
|
|
fileListBody: document.getElementById('file-list-body'),
|
|
breadcrumb: document.getElementById('breadcrumb'),
|
|
pathDisplay: document.getElementById('path-display'),
|
|
connectionStatus: document.getElementById('connection-status'),
|
|
btnUpload: document.getElementById('btn-upload'),
|
|
btnDownload: document.getElementById('btn-download'),
|
|
btnDelete: document.getElementById('btn-delete'),
|
|
btnMove: document.getElementById('btn-move'),
|
|
btnRefresh: document.getElementById('btn-refresh'),
|
|
btnNewDir: document.getElementById('btn-new-dir'),
|
|
selectAll: document.getElementById('select-all'),
|
|
fileInput: document.getElementById('file-input'),
|
|
dropZone: document.getElementById('drop-zone'),
|
|
previewModal: document.getElementById('preview-modal'),
|
|
previewTitle: document.getElementById('preview-title'),
|
|
previewBody: document.getElementById('preview-body'),
|
|
previewClose: document.getElementById('preview-close'),
|
|
moveModal: document.getElementById('move-modal'),
|
|
moveClose: document.getElementById('move-close'),
|
|
moveConfirm: document.getElementById('move-confirm'),
|
|
moveCancel: document.getElementById('move-cancel'),
|
|
moveFilename: document.getElementById('move-filename'),
|
|
moveBreadcrumb: document.getElementById('move-breadcrumb'),
|
|
dirTree: document.getElementById('dir-tree'),
|
|
moveSource: document.getElementById('move-source'),
|
|
moveDest: document.getElementById('move-dest'),
|
|
newDirModal: document.getElementById('new-dir-modal'),
|
|
newDirClose: document.getElementById('new-dir-close'),
|
|
newDirConfirm: document.getElementById('new-dir-confirm'),
|
|
newDirCancel: document.getElementById('new-dir-cancel'),
|
|
newDirName: document.getElementById('new-dir-name'),
|
|
renameModal: document.getElementById('rename-modal'),
|
|
renameClose: document.getElementById('rename-close'),
|
|
renameConfirm: document.getElementById('rename-confirm'),
|
|
renameCancel: document.getElementById('rename-cancel'),
|
|
renameName: document.getElementById('rename-name'),
|
|
renamePath: document.getElementById('rename-path'),
|
|
uploadModal: document.getElementById('upload-modal'),
|
|
uploadClose: document.getElementById('upload-close'),
|
|
uploadCancel: document.getElementById('upload-cancel'),
|
|
uploadConfirm: document.getElementById('upload-confirm'),
|
|
uploadList: document.getElementById('upload-list'),
|
|
uploadProgressContainer: document.getElementById('upload-progress-container'),
|
|
uploadProgressBar: document.getElementById('upload-progress-bar'),
|
|
uploadStats: document.getElementById('upload-stats'),
|
|
searchInput: document.getElementById('search-input'),
|
|
searchBtn: document.getElementById('search-btn'),
|
|
notification: document.getElementById('notification')
|
|
};
|
|
|
|
let uploadXhrs = [];
|
|
let isUploading = false;
|
|
let searchResults = [];
|
|
|
|
function init() {
|
|
loadFiles(currentPath);
|
|
setupEventListeners();
|
|
startWatch();
|
|
}
|
|
|
|
function loadFiles(path) {
|
|
elements.fileListBody.innerHTML = '<tr><td colspan="5" class="loading">加载中...</td></tr>';
|
|
|
|
fetch(`${API_BASE}/files?path=${encodeURIComponent(path)}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
renderFileList(data.files);
|
|
updateBreadcrumb(path);
|
|
elements.pathDisplay.textContent = path || '/';
|
|
})
|
|
.catch(error => {
|
|
showNotification('加载失败: ' + error.message, 'error');
|
|
elements.fileListBody.innerHTML = '<tr><td colspan="5" class="empty-message"><span class="icon">❌</span>加载失败</td></tr>';
|
|
});
|
|
}
|
|
|
|
function renderFileList(files) {
|
|
if (!files || files.length === 0) {
|
|
elements.fileListBody.innerHTML = '<tr><td colspan="5" class="empty-message"><span class="icon">📂</span>此目录为空</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const dirs = files.filter(f => f.isDir).sort((a, b) => a.name.localeCompare(b.name));
|
|
const regularFiles = files.filter(f => !f.isDir).sort((a, b) => a.name.localeCompare(b.name));
|
|
const sortedFiles = [...dirs, ...regularFiles];
|
|
|
|
elements.fileListBody.innerHTML = sortedFiles.map(file => `
|
|
<tr data-path="${file.path}" class="${selectedFiles.has(file.path) ? 'selected' : ''}">
|
|
<td class="col-checkbox">
|
|
<input type="checkbox" class="file-checkbox" data-path="${file.path}" ${selectedFiles.has(file.path) ? 'checked' : ''}>
|
|
</td>
|
|
<td class="col-name">
|
|
<span class="file-name ${file.isDir ? 'is-dir' : ''}" data-path="${file.path}" data-is-dir="${file.isDir}">
|
|
<span class="file-icon">${getFileIcon(file.name, file.isDir)}</span>
|
|
${escapeHtml(file.name)}
|
|
</span>
|
|
</td>
|
|
<td class="col-size">${file.isDir ? '-' : formatFileSize(file.size)}</td>
|
|
<td class="col-time">${formatTime(file.modTime)}</td>
|
|
<td class="col-actions">
|
|
<div class="file-actions">
|
|
${file.isDir ? '' : `<button class="action-btn" data-action="download" data-path="${file.path}" title="下载">📥</button>`}
|
|
${file.canPreview ? `<button class="action-btn" data-action="preview" data-path="${file.path}" title="预览">👁️</button>` : ''}
|
|
<button class="action-btn" data-action="rename" data-path="${file.path}" title="重命名">✏️</button>
|
|
<button class="action-btn delete" data-action="delete" data-path="${file.path}" title="删除">🗑️</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
updateButtonStates();
|
|
}
|
|
|
|
function getFileIcon(name, isDir) {
|
|
if (isDir) return '📁';
|
|
|
|
const ext = name.split('.').pop().toLowerCase();
|
|
const icons = {
|
|
pdf: '📕',
|
|
doc: '📘', docx: '📘',
|
|
xls: '📗', xlsx: '📗',
|
|
ppt: '📙', pptx: '📙',
|
|
txt: '📄',
|
|
md: '📝',
|
|
html: '🌐', htm: '🌐',
|
|
css: '🎨',
|
|
js: '📜',
|
|
json: '{ }',
|
|
xml: '📋',
|
|
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', bmp: '🖼️', webp: '🖼️',
|
|
mp3: '🎵', wav: '🎵', ogg: '🎵', flac: '🎵',
|
|
mp4: '🎬', avi: '🎬', mkv: '🎬', mov: '🎬', webm: '🎬',
|
|
zip: '📦', rar: '📦', '7z': '📦', tar: '📦', gz: '📦',
|
|
exe: '⚙️', app: '⚙️',
|
|
sh: '💻',
|
|
go: '🔷',
|
|
py: '🐍',
|
|
java: '☕',
|
|
c: '🔵', cpp: '🔵', h: '🔵',
|
|
sql: '🗃️',
|
|
csv: '📊',
|
|
svg: '🎯'
|
|
};
|
|
return icons[ext] || '📄';
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatTime(timeStr) {
|
|
const date = new Date(timeStr);
|
|
const now = new Date();
|
|
const isToday = date.toDateString() === now.toDateString();
|
|
|
|
if (isToday) {
|
|
return '今天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
return date.toLocaleDateString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function updateBreadcrumb(path) {
|
|
if (!path || path === '/') {
|
|
elements.breadcrumb.innerHTML = '<span class="crumb" data-path="/">根目录</span>';
|
|
return;
|
|
}
|
|
|
|
const parts = path.split('/').filter(p => p);
|
|
let html = '<span class="crumb" data-path="/">根目录</span>';
|
|
|
|
let currentPath = '';
|
|
parts.forEach((part, index) => {
|
|
currentPath += '/' + part;
|
|
const isLast = index === parts.length - 1;
|
|
html += `<span class="crumb ${isLast ? '' : ''}" data-path="${currentPath}">${escapeHtml(part)}</span>`;
|
|
});
|
|
|
|
elements.breadcrumb.innerHTML = html;
|
|
}
|
|
|
|
function updateButtonStates() {
|
|
const count = selectedFiles.size;
|
|
elements.btnDownload.disabled = count === 0;
|
|
elements.btnDelete.disabled = count === 0;
|
|
elements.btnMove.disabled = count !== 1;
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
elements.breadcrumb.addEventListener('click', e => {
|
|
if (e.target.classList.contains('crumb')) {
|
|
currentPath = e.target.dataset.path;
|
|
loadFiles(currentPath);
|
|
}
|
|
});
|
|
|
|
elements.fileListBody.addEventListener('click', e => {
|
|
const checkbox = e.target.closest('.file-checkbox');
|
|
const fileName = e.target.closest('.file-name');
|
|
const actionBtn = e.target.closest('.action-btn');
|
|
|
|
if (checkbox) {
|
|
toggleFileSelection(checkbox.dataset.path, checkbox.checked);
|
|
} else if (fileName) {
|
|
const isDir = fileName.dataset.isDir === 'true';
|
|
const path = fileName.dataset.path;
|
|
|
|
if (isDir) {
|
|
currentPath = path;
|
|
loadFiles(currentPath);
|
|
}
|
|
} else if (actionBtn) {
|
|
const action = actionBtn.dataset.action;
|
|
const path = actionBtn.dataset.path;
|
|
|
|
switch (action) {
|
|
case 'download':
|
|
downloadFile(path);
|
|
break;
|
|
case 'preview':
|
|
previewFile(path);
|
|
break;
|
|
case 'rename':
|
|
showRenameModal(path);
|
|
break;
|
|
case 'delete':
|
|
deleteFiles([path]);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
elements.selectAll.addEventListener('change', e => {
|
|
const checkboxes = elements.fileListBody.querySelectorAll('.file-checkbox');
|
|
checkboxes.forEach(cb => {
|
|
if (e.target.checked) {
|
|
selectedFiles.add(cb.dataset.path);
|
|
cb.checked = true;
|
|
} else {
|
|
selectedFiles.delete(cb.dataset.path);
|
|
cb.checked = false;
|
|
}
|
|
});
|
|
updateButtonStates();
|
|
updateRowSelections();
|
|
});
|
|
|
|
elements.btnUpload.addEventListener('click', () => {
|
|
elements.fileInput.click();
|
|
});
|
|
|
|
elements.btnDownload.addEventListener('click', () => {
|
|
downloadSelected();
|
|
});
|
|
|
|
elements.btnDelete.addEventListener('click', () => {
|
|
if (selectedFiles.size > 0) {
|
|
if (confirm(`确定要删除选中的 ${selectedFiles.size} 个项目吗?`)) {
|
|
deleteFiles(Array.from(selectedFiles));
|
|
}
|
|
}
|
|
});
|
|
|
|
elements.btnMove.addEventListener('click', () => {
|
|
if (selectedFiles.size === 1) {
|
|
showMoveModal(Array.from(selectedFiles)[0]);
|
|
}
|
|
});
|
|
|
|
elements.btnRefresh.addEventListener('click', () => {
|
|
loadFiles(currentPath);
|
|
});
|
|
|
|
elements.btnNewDir.addEventListener('click', () => {
|
|
elements.newDirName.value = '';
|
|
elements.newDirModal.classList.add('active');
|
|
elements.newDirName.focus();
|
|
});
|
|
|
|
elements.fileInput.addEventListener('change', e => {
|
|
uploadFiles(e.target.files);
|
|
e.target.value = '';
|
|
});
|
|
|
|
elements.searchBtn.addEventListener('click', () => {
|
|
searchFiles();
|
|
});
|
|
|
|
elements.searchInput.addEventListener('keypress', e => {
|
|
if (e.key === 'Enter') searchFiles();
|
|
});
|
|
|
|
elements.searchInput.addEventListener('input', () => {
|
|
if (elements.searchInput.value === '') {
|
|
loadFiles(currentPath);
|
|
}
|
|
});
|
|
|
|
setupDragDrop();
|
|
setupModals();
|
|
}
|
|
|
|
function searchFiles() {
|
|
const keyword = elements.searchInput.value.trim();
|
|
if (!keyword) {
|
|
loadFiles(currentPath);
|
|
return;
|
|
}
|
|
|
|
elements.fileListBody.innerHTML = '<tr><td colspan="5" class="loading">搜索中...</td></tr>';
|
|
|
|
searchAllDirectories('/', keyword.toLowerCase(), []);
|
|
}
|
|
|
|
async function searchAllDirectories(path, keyword, results) {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/files?path=${encodeURIComponent(path)}`);
|
|
const data = await response.json();
|
|
const files = data.files || [];
|
|
|
|
files.forEach(file => {
|
|
if (file.name.toLowerCase().includes(keyword)) {
|
|
results.push(file);
|
|
}
|
|
});
|
|
|
|
const dirs = files.filter(f => f.isDir);
|
|
if (dirs.length > 0) {
|
|
for (const dir of dirs) {
|
|
await searchAllDirectories(dir.path, keyword, results);
|
|
}
|
|
}
|
|
|
|
if (path === '/') {
|
|
if (results.length > 0) {
|
|
renderSearchResults(results, keyword);
|
|
} else {
|
|
elements.fileListBody.innerHTML = '<tr><td colspan="5" class="empty-message"><span class="icon">🔍</span>未找到匹配的文件</td></tr>';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (path === '/') {
|
|
showNotification('搜索失败: ' + error.message, 'error');
|
|
elements.fileListBody.innerHTML = '<tr><td colspan="5" class="empty-message"><span class="icon">❌</span>搜索失败</td></tr>';
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderSearchResults(files, keyword) {
|
|
const sortedFiles = files.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
elements.fileListBody.innerHTML = sortedFiles.map(file => `
|
|
<tr data-path="${file.path}" class="${selectedFiles.has(file.path) ? 'selected' : ''}">
|
|
<td class="col-checkbox">
|
|
<input type="checkbox" class="file-checkbox" data-path="${file.path}" ${selectedFiles.has(file.path) ? 'checked' : ''}>
|
|
</td>
|
|
<td class="col-name">
|
|
<span class="file-name ${file.isDir ? 'is-dir' : ''}" data-path="${file.path}" data-is-dir="${file.isDir}">
|
|
<span class="file-icon">${getFileIcon(file.name, file.isDir)}</span>
|
|
${highlightKeyword(escapeHtml(file.name), keyword)}
|
|
</span>
|
|
</td>
|
|
<td class="col-size">${file.isDir ? '-' : formatFileSize(file.size)}</td>
|
|
<td class="col-time">${formatTime(file.modTime)}</td>
|
|
<td class="col-actions">
|
|
<div class="file-actions">
|
|
${file.isDir ? '' : `<button class="action-btn" data-action="download" data-path="${file.path}" title="下载">📥</button>`}
|
|
${file.canPreview ? `<button class="action-btn" data-action="preview" data-path="${file.path}" title="预览">👁️</button>` : ''}
|
|
<button class="action-btn" data-action="rename" data-path="${file.path}" title="重命名">✏️</button>
|
|
<button class="action-btn delete" data-action="delete" data-path="${file.path}" title="删除">🗑️</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
updateButtonStates();
|
|
}
|
|
|
|
function highlightKeyword(text, keyword) {
|
|
if (!keyword) return text;
|
|
const regex = new RegExp(`(${keyword})`, 'gi');
|
|
return text.replace(regex, '<mark style="background: #ffeb3b; padding: 0 2px;">$1</mark>');
|
|
}
|
|
|
|
function toggleFileSelection(path, selected) {
|
|
if (selected) {
|
|
selectedFiles.add(path);
|
|
} else {
|
|
selectedFiles.delete(path);
|
|
}
|
|
const checkbox = elements.fileListBody.querySelector(`.file-checkbox[data-path="${path}"]`);
|
|
if (checkbox) {
|
|
checkbox.checked = selected;
|
|
}
|
|
updateRowSelections();
|
|
updateButtonStates();
|
|
updateSelectAllState();
|
|
}
|
|
|
|
function updateRowSelections() {
|
|
const rows = elements.fileListBody.querySelectorAll('tr');
|
|
rows.forEach(row => {
|
|
if (selectedFiles.has(row.dataset.path)) {
|
|
row.classList.add('selected');
|
|
} else {
|
|
row.classList.remove('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateSelectAllState() {
|
|
const checkboxes = elements.fileListBody.querySelectorAll('.file-checkbox');
|
|
const checkedCount = elements.fileListBody.querySelectorAll('.file-checkbox:checked').length;
|
|
elements.selectAll.checked = checkedCount === checkboxes.length && checkboxes.length > 0;
|
|
elements.selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
|
|
}
|
|
|
|
function setupDragDrop() {
|
|
document.addEventListener('dragenter', e => {
|
|
e.preventDefault();
|
|
elements.dropZone.classList.add('active');
|
|
});
|
|
|
|
elements.dropZone.addEventListener('dragleave', e => {
|
|
if (e.target === elements.dropZone) {
|
|
elements.dropZone.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
elements.dropZone.addEventListener('dragover', e => {
|
|
e.preventDefault();
|
|
});
|
|
|
|
elements.dropZone.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
elements.dropZone.classList.remove('active');
|
|
uploadFiles(e.dataTransfer.files);
|
|
});
|
|
}
|
|
|
|
function setupModals() {
|
|
elements.previewClose.addEventListener('click', () => {
|
|
elements.previewModal.classList.remove('active');
|
|
});
|
|
|
|
elements.moveClose.addEventListener('click', () => {
|
|
elements.moveModal.classList.remove('active');
|
|
});
|
|
|
|
elements.moveCancel.addEventListener('click', () => {
|
|
elements.moveModal.classList.remove('active');
|
|
});
|
|
|
|
elements.moveConfirm.addEventListener('click', () => {
|
|
moveFile();
|
|
});
|
|
|
|
elements.newDirClose.addEventListener('click', () => {
|
|
elements.newDirModal.classList.remove('active');
|
|
});
|
|
|
|
elements.newDirCancel.addEventListener('click', () => {
|
|
elements.newDirModal.classList.remove('active');
|
|
});
|
|
|
|
elements.newDirConfirm.addEventListener('click', () => {
|
|
createDirectory();
|
|
});
|
|
|
|
elements.newDirName.addEventListener('keypress', e => {
|
|
if (e.key === 'Enter') createDirectory();
|
|
});
|
|
|
|
elements.renameClose.addEventListener('click', () => {
|
|
elements.renameModal.classList.remove('active');
|
|
});
|
|
|
|
elements.renameCancel.addEventListener('click', () => {
|
|
elements.renameModal.classList.remove('active');
|
|
});
|
|
|
|
elements.renameConfirm.addEventListener('click', () => {
|
|
renameFile();
|
|
});
|
|
|
|
elements.renameName.addEventListener('keypress', e => {
|
|
if (e.key === 'Enter') renameFile();
|
|
});
|
|
|
|
elements.uploadClose.addEventListener('click', () => {
|
|
if (!isUploading) {
|
|
elements.uploadModal.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
elements.uploadCancel.addEventListener('click', () => {
|
|
if (isUploading) {
|
|
uploadXhrs.forEach(xhr => xhr.abort());
|
|
uploadXhrs = [];
|
|
isUploading = false;
|
|
elements.uploadModal.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
elements.uploadConfirm.addEventListener('click', () => {
|
|
elements.uploadModal.classList.remove('active');
|
|
});
|
|
|
|
document.addEventListener('click', e => {
|
|
if (e.target === elements.previewModal) {
|
|
elements.previewModal.classList.remove('active');
|
|
}
|
|
if (e.target === elements.moveModal) {
|
|
elements.moveModal.classList.remove('active');
|
|
}
|
|
if (e.target === elements.newDirModal) {
|
|
elements.newDirModal.classList.remove('active');
|
|
}
|
|
if (e.target === elements.renameModal) {
|
|
elements.renameModal.classList.remove('active');
|
|
}
|
|
if (e.target === elements.uploadModal && !isUploading) {
|
|
elements.uploadModal.classList.remove('active');
|
|
}
|
|
});
|
|
}
|
|
|
|
function uploadFiles(files) {
|
|
if (!files || files.length === 0) return;
|
|
|
|
const fileArray = Array.from(files);
|
|
elements.uploadList.innerHTML = fileArray.map((file, index) => `
|
|
<div class="upload-item" data-index="${index}">
|
|
<span class="upload-item-icon">${getFileIcon(file.name, false)}</span>
|
|
<div class="upload-item-info">
|
|
<div class="upload-item-name">${escapeHtml(file.name)}</div>
|
|
<div class="upload-item-status" id="upload-status-${index}">等待中...</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
elements.uploadProgressContainer.classList.add('active');
|
|
elements.uploadProgressBar.style.width = '0%';
|
|
elements.uploadStats.classList.add('active');
|
|
elements.uploadCancel.style.display = 'block';
|
|
elements.uploadConfirm.style.display = 'none';
|
|
elements.uploadModal.classList.add('active');
|
|
|
|
let completed = 0;
|
|
const total = fileArray.length;
|
|
isUploading = true;
|
|
uploadXhrs = [];
|
|
|
|
fileArray.forEach((file, index) => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
uploadXhrs.push(xhr);
|
|
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
const percent = Math.round((e.loaded / e.total) * 100);
|
|
document.getElementById(`upload-status-${index}`).textContent = `上传中 ${percent}%`;
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('load', () => {
|
|
completed++;
|
|
const totalPercent = Math.round((completed / total) * 100);
|
|
elements.uploadProgressBar.style.width = totalPercent + '%';
|
|
|
|
try {
|
|
const response = JSON.parse(xhr.responseText);
|
|
if (response.success) {
|
|
document.getElementById(`upload-status-${index}`).textContent = '完成 ✓';
|
|
} else {
|
|
document.getElementById(`upload-status-${index}`).textContent = '失败 ✗';
|
|
}
|
|
} catch (e) {
|
|
document.getElementById(`upload-status-${index}`).textContent = '失败 ✗';
|
|
}
|
|
|
|
if (completed === total) {
|
|
isUploading = false;
|
|
elements.uploadCancel.style.display = 'none';
|
|
elements.uploadConfirm.style.display = 'block';
|
|
elements.uploadStats.textContent = `上传完成: 成功 ${completed} 个`;
|
|
loadFiles(currentPath);
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('error', () => {
|
|
completed++;
|
|
document.getElementById(`upload-status-${index}`).textContent = '失败 ✗';
|
|
const totalPercent = Math.round((completed / total) * 100);
|
|
elements.uploadProgressBar.style.width = totalPercent + '%';
|
|
|
|
if (completed === total) {
|
|
isUploading = false;
|
|
elements.uploadCancel.style.display = 'none';
|
|
elements.uploadConfirm.style.display = 'block';
|
|
elements.uploadStats.textContent = `上传完成: 成功 ${completed} 个`;
|
|
loadFiles(currentPath);
|
|
}
|
|
});
|
|
|
|
xhr.open('POST', `${API_BASE}/upload?path=${encodeURIComponent(currentPath)}`);
|
|
xhr.send(formData);
|
|
});
|
|
}
|
|
|
|
function downloadFile(path) {
|
|
const url = `${API_BASE}/download?path=${encodeURIComponent(path)}`;
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = '';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
|
|
function downloadSelected() {
|
|
const paths = Array.from(selectedFiles);
|
|
if (paths.length === 1) {
|
|
downloadFile(paths[0]);
|
|
} else {
|
|
const url = `${API_BASE}/download?paths=${encodeURIComponent(JSON.stringify(paths))}`;
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = '';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
}
|
|
|
|
function deleteFiles(paths) {
|
|
fetch(`${API_BASE}/files`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(paths)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showNotification(`已删除 ${paths.length} 个项目`, 'success');
|
|
selectedFiles.clear();
|
|
loadFiles(currentPath);
|
|
} else {
|
|
showNotification('删除失败', 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showNotification('删除失败: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function showMoveModal(sourcePath) {
|
|
elements.moveSource.value = sourcePath;
|
|
elements.moveFilename.textContent = '移动: ' + sourcePath;
|
|
loadDirectoryTree('/');
|
|
elements.moveModal.classList.add('active');
|
|
}
|
|
|
|
function loadDirectoryTree(path) {
|
|
fetch(`${API_BASE}/files?path=${encodeURIComponent(path)}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
renderDirectoryTree(data.files || [], path);
|
|
updateMoveBreadcrumb(path);
|
|
});
|
|
}
|
|
|
|
function renderDirectoryTree(files, currentPath) {
|
|
const dirs = (files || []).filter(f => f.isDir);
|
|
|
|
if (dirs.length === 0) {
|
|
elements.dirTree.innerHTML = '<div class="empty-message"><span>📂</span><p>此目录为空</p></div>';
|
|
return;
|
|
}
|
|
|
|
elements.dirTree.innerHTML = dirs.map(dir => `
|
|
<div class="dir-item" data-path="${dir.path}">
|
|
<span>📁</span>
|
|
<span>${escapeHtml(dir.name)}</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
elements.dirTree.querySelectorAll('.dir-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
loadDirectoryTree(item.dataset.path);
|
|
});
|
|
});
|
|
|
|
const currentItem = elements.dirTree.querySelector(`.dir-item[data-path="${currentPath}"]`);
|
|
if (currentItem) {
|
|
currentItem.classList.add('current');
|
|
}
|
|
}
|
|
|
|
function updateMoveBreadcrumb(path) {
|
|
if (!path || path === '/') {
|
|
elements.moveBreadcrumb.innerHTML = '<span class="crumb" data-path="/">根目录</span>';
|
|
return;
|
|
}
|
|
|
|
const parts = path.split('/').filter(p => p);
|
|
let html = '<span class="crumb" data-path="/">根目录</span>';
|
|
|
|
let currentPath = '';
|
|
parts.forEach(part => {
|
|
currentPath += '/' + part;
|
|
html += `<span class="crumb" data-path="${currentPath}">${escapeHtml(part)}</span>`;
|
|
});
|
|
|
|
elements.moveBreadcrumb.innerHTML = html;
|
|
|
|
elements.moveBreadcrumb.querySelectorAll('.crumb').forEach(crumb => {
|
|
crumb.addEventListener('click', () => {
|
|
loadDirectoryTree(crumb.dataset.path);
|
|
});
|
|
});
|
|
}
|
|
|
|
function moveFile() {
|
|
const sourcePath = elements.moveSource.value;
|
|
const destPath = currentPath;
|
|
|
|
if (!sourcePath || !destPath) return;
|
|
|
|
const sourceName = sourcePath.split('/').pop();
|
|
const newPath = destPath === '/' ? sourceName : destPath + '/' + sourceName;
|
|
|
|
if (sourcePath === newPath) {
|
|
showNotification('源路径和目标路径相同', 'error');
|
|
return;
|
|
}
|
|
|
|
fetch(`${API_BASE}/move`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ srcPath: sourcePath, destPath: newPath })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showNotification('移动成功', 'success');
|
|
elements.moveModal.classList.remove('active');
|
|
selectedFiles.delete(sourcePath);
|
|
loadFiles(currentPath);
|
|
} else {
|
|
showNotification('移动失败: ' + (data.error || '未知错误'), 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showNotification('移动失败: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function showRenameModal(path) {
|
|
const name = path.split('/').pop();
|
|
elements.renamePath.value = path;
|
|
elements.renameName.value = name;
|
|
elements.renameModal.classList.add('active');
|
|
elements.renameName.focus();
|
|
elements.renameName.select();
|
|
}
|
|
|
|
function renameFile() {
|
|
const path = elements.renamePath.value;
|
|
const newName = elements.renameName.value.trim();
|
|
|
|
if (!path || !newName) {
|
|
showNotification('请输入新名称', 'error');
|
|
return;
|
|
}
|
|
|
|
const dir = path.substring(0, path.lastIndexOf('/'));
|
|
const newPath = dir ? dir + '/' + newName : newName;
|
|
|
|
if (path === newPath) {
|
|
elements.renameModal.classList.remove('active');
|
|
return;
|
|
}
|
|
|
|
fetch(`${API_BASE}/rename`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ oldPath: path, newName: newName })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showNotification('重命名成功', 'success');
|
|
elements.renameModal.classList.remove('active');
|
|
selectedFiles.delete(path);
|
|
if (selectedFiles.has(newPath)) {
|
|
selectedFiles.delete(newPath);
|
|
}
|
|
loadFiles(currentPath);
|
|
} else {
|
|
showNotification('重命名失败: ' + (data.error || '未知错误'), 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showNotification('重命名失败: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function createDirectory() {
|
|
const name = elements.newDirName.value.trim();
|
|
|
|
if (!name) {
|
|
showNotification('请输入文件夹名称', 'error');
|
|
return;
|
|
}
|
|
|
|
fetch(`${API_BASE}/dir`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: currentPath, name: name })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showNotification('文件夹创建成功', 'success');
|
|
elements.newDirModal.classList.remove('active');
|
|
loadFiles(currentPath);
|
|
} else {
|
|
showNotification('创建失败: ' + (data.error || '未知错误'), 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showNotification('创建失败: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function previewFile(path) {
|
|
const name = path.split('/').pop();
|
|
elements.previewTitle.textContent = name;
|
|
elements.previewBody.innerHTML = '<div class="loading">加载中...</div>';
|
|
elements.previewModal.classList.add('active');
|
|
|
|
fetch(`${API_BASE}/preview?path=${encodeURIComponent(path)}`)
|
|
.then(response => {
|
|
const contentType = response.headers.get('Content-Type');
|
|
|
|
if (contentType && contentType.startsWith('image/')) {
|
|
return response.blob().then(blob => {
|
|
return { type: 'image', data: URL.createObjectURL(blob) };
|
|
});
|
|
} else if (contentType && contentType.startsWith('text/')) {
|
|
return response.text().then(text => {
|
|
return { type: 'text', data: text };
|
|
});
|
|
} else if (contentType && contentType === 'application/pdf') {
|
|
return response.blob().then(blob => {
|
|
return { type: 'pdf', data: URL.createObjectURL(blob) };
|
|
});
|
|
} else {
|
|
return { type: 'unsupported', message: '此文件类型不支持预览' };
|
|
}
|
|
})
|
|
.then(result => {
|
|
switch (result.type) {
|
|
case 'image':
|
|
elements.previewBody.innerHTML = `<img src="${result.data}" class="preview-image" alt="${escapeHtml(name)}">`;
|
|
break;
|
|
case 'text':
|
|
elements.previewBody.innerHTML = `<pre class="preview-text">${escapeHtml(result.data)}</pre>`;
|
|
break;
|
|
case 'pdf':
|
|
elements.previewBody.innerHTML = `<iframe src="${result.data}#toolbar=0" width="100%" height="600" style="border: none;"></iframe>`;
|
|
break;
|
|
default:
|
|
elements.previewBody.innerHTML = `
|
|
<div class="preview-placeholder">
|
|
<span class="icon">📄</span>
|
|
<p>${result.message || '此文件类型不支持预览'}</p>
|
|
<p><a href="${API_BASE}/download?path=${encodeURIComponent(path)}" target="_blank">点击下载查看</a></p>
|
|
</div>
|
|
`;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
elements.previewBody.innerHTML = `
|
|
<div class="preview-placeholder">
|
|
<span class="icon">❌</span>
|
|
<p>预览失败: ${error.message}</p>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
function startWatch() {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
|
|
try {
|
|
eventSource = new EventSource(`${API_BASE}/watch`);
|
|
|
|
eventSource.onopen = () => {
|
|
elements.connectionStatus.textContent = '● 已连接';
|
|
elements.connectionStatus.className = 'connected';
|
|
};
|
|
|
|
eventSource.onerror = () => {
|
|
if (eventSource.readyState === EventSource.CLOSED) return;
|
|
elements.connectionStatus.textContent = '● 已连接';
|
|
elements.connectionStatus.className = 'connected';
|
|
};
|
|
|
|
eventSource.addEventListener('message', e => {
|
|
try {
|
|
const event = JSON.parse(e.data);
|
|
handleWatchEvent(event);
|
|
} catch (err) {
|
|
}
|
|
});
|
|
} catch (err) {
|
|
elements.connectionStatus.textContent = '● 已连接';
|
|
elements.connectionStatus.className = 'connected';
|
|
}
|
|
}
|
|
|
|
function handleWatchEvent(event) {
|
|
const inCurrentDir = currentPath === '/' || event.path.startsWith(currentPath + '/') ||
|
|
event.path === currentPath;
|
|
|
|
if (!inCurrentDir) return;
|
|
|
|
const pathParts = event.path.replace(rootDir, '').split('/').filter(p => p);
|
|
const eventDir = '/' + pathParts.slice(0, -1).join('/');
|
|
|
|
if (eventDir !== currentPath) return;
|
|
|
|
switch (event.type) {
|
|
case 'create':
|
|
showNotification(`新建文件: ${event.name}`, 'info');
|
|
loadFiles(currentPath);
|
|
break;
|
|
case 'delete':
|
|
showNotification(`已删除: ${event.name}`, 'info');
|
|
selectedFiles.delete(event.path.replace(rootDir, ''));
|
|
loadFiles(currentPath);
|
|
break;
|
|
case 'rename':
|
|
case 'move':
|
|
showNotification(`文件已移动: ${event.name}`, 'info');
|
|
loadFiles(currentPath);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function showNotification(message, type = 'info') {
|
|
elements.notification.textContent = message;
|
|
elements.notification.className = 'notification ' + type;
|
|
elements.notification.classList.add('show');
|
|
|
|
setTimeout(() => {
|
|
elements.notification.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (bytes === 0) return '-';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
const rootDir = '';
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
})();
|