501 lines
15 KiB
JavaScript
501 lines
15 KiB
JavaScript
class FilePanel {
|
|
constructor() {
|
|
this.isVisible = false;
|
|
this.localPath = '';
|
|
this.remotePath = '';
|
|
this.localFiles = [];
|
|
this.remoteFiles = [];
|
|
this.selectedLocal = null;
|
|
this.selectedRemote = null;
|
|
this.transfers = [];
|
|
this.chunkSize = 5 * 1024 * 1024;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.createPanel();
|
|
this.bindEvents();
|
|
}
|
|
|
|
createPanel() {
|
|
const panel = document.createElement('div');
|
|
panel.id = 'file-panel';
|
|
panel.innerHTML = `
|
|
<div class="file-panel-header">
|
|
<span>文件传输</span>
|
|
<button class="close-btn" id="close-panel">×</button>
|
|
</div>
|
|
<div class="file-panel-body">
|
|
<div class="file-browser" id="local-browser">
|
|
<div class="browser-header">
|
|
<span class="browser-title">本地文件</span>
|
|
<div class="path-nav">
|
|
<button class="nav-btn" id="local-up" title="上级目录">↑</button>
|
|
<input type="text" class="path-input" id="local-path" readonly>
|
|
</div>
|
|
</div>
|
|
<div class="file-list" id="local-files"></div>
|
|
<div class="browser-actions">
|
|
<button class="action-btn" id="select-local-file">选择文件</button>
|
|
<button class="action-btn primary" id="upload-btn" disabled>上传 →</button>
|
|
</div>
|
|
</div>
|
|
<div class="file-browser" id="remote-browser">
|
|
<div class="browser-header">
|
|
<span class="browser-title">远程文件</span>
|
|
<div class="path-nav">
|
|
<button class="nav-btn" id="remote-up" title="上级目录">↑</button>
|
|
<input type="text" class="path-input" id="remote-path" readonly>
|
|
</div>
|
|
</div>
|
|
<div class="file-list" id="remote-files"></div>
|
|
<div class="browser-actions">
|
|
<button class="action-btn primary" id="download-btn" disabled>← 下载</button>
|
|
<button class="action-btn danger" id="delete-btn" disabled>删除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="transfer-list" id="transfer-list"></div>
|
|
<input type="file" id="file-input" style="display:none" multiple>
|
|
`;
|
|
document.body.appendChild(panel);
|
|
this.panel = panel;
|
|
}
|
|
|
|
bindEvents() {
|
|
document.getElementById('close-panel').addEventListener('click', () => this.hide());
|
|
|
|
document.getElementById('local-up').addEventListener('click', () => this.navigateLocalUp());
|
|
document.getElementById('remote-up').addEventListener('click', () => this.navigateRemoteUp());
|
|
|
|
document.getElementById('select-local-file').addEventListener('click', () => {
|
|
document.getElementById('file-input').click();
|
|
});
|
|
|
|
document.getElementById('file-input').addEventListener('change', (e) => {
|
|
this.handleLocalFileSelect(e.target.files);
|
|
});
|
|
|
|
document.getElementById('upload-btn').addEventListener('click', () => this.uploadSelected());
|
|
document.getElementById('download-btn').addEventListener('click', () => this.downloadSelected());
|
|
document.getElementById('delete-btn').addEventListener('click', () => this.deleteSelected());
|
|
|
|
document.getElementById('remote-files').addEventListener('dblclick', (e) => {
|
|
const item = e.target.closest('.file-item');
|
|
if (item && item.dataset.isDirectory === 'true') {
|
|
this.navigateRemote(item.dataset.name);
|
|
}
|
|
});
|
|
|
|
document.getElementById('remote-files').addEventListener('click', (e) => {
|
|
const item = e.target.closest('.file-item');
|
|
if (item) {
|
|
this.selectRemote(item.dataset.name, item.dataset.isDirectory === 'true');
|
|
}
|
|
});
|
|
}
|
|
|
|
show() {
|
|
this.isVisible = true;
|
|
this.panel.classList.add('visible');
|
|
this.refreshRemote();
|
|
}
|
|
|
|
hide() {
|
|
this.isVisible = false;
|
|
this.panel.classList.remove('visible');
|
|
}
|
|
|
|
toggle() {
|
|
if (this.isVisible) {
|
|
this.hide();
|
|
} else {
|
|
this.show();
|
|
}
|
|
}
|
|
|
|
async refreshRemote() {
|
|
try {
|
|
const res = await fetch(`/api/files/browse?path=${encodeURIComponent(this.remotePath)}`);
|
|
const data = await res.json();
|
|
|
|
if (data.error) {
|
|
console.error('Browse error:', data.error);
|
|
return;
|
|
}
|
|
|
|
this.remoteFiles = data.items || [];
|
|
this.remotePath = data.currentPath || '';
|
|
|
|
document.getElementById('remote-path').value = this.remotePath || '/';
|
|
this.renderRemoteFiles();
|
|
} catch (error) {
|
|
console.error('Failed to refresh remote:', error);
|
|
}
|
|
}
|
|
|
|
renderRemoteFiles() {
|
|
const container = document.getElementById('remote-files');
|
|
|
|
if (this.remotePath) {
|
|
container.innerHTML = `<div class="file-item directory" data-name=".." data-is-directory="true">
|
|
<span class="file-icon">📁</span>
|
|
<span class="file-name">..</span>
|
|
</div>`;
|
|
} else {
|
|
container.innerHTML = '';
|
|
}
|
|
|
|
this.remoteFiles.forEach(item => {
|
|
const div = document.createElement('div');
|
|
div.className = `file-item ${item.isDirectory ? 'directory' : 'file'}`;
|
|
div.dataset.name = item.name;
|
|
div.dataset.isDirectory = item.isDirectory;
|
|
div.innerHTML = `
|
|
<span class="file-icon">${item.isDirectory ? '📁' : this.getFileIcon(item.name)}</span>
|
|
<span class="file-name">${item.name}</span>
|
|
<span class="file-size">${item.isDirectory ? '' : this.formatSize(item.size)}</span>
|
|
`;
|
|
container.appendChild(div);
|
|
});
|
|
|
|
this.selectedRemote = null;
|
|
this.updateRemoteActions();
|
|
}
|
|
|
|
selectRemote(name, isDirectory) {
|
|
const items = document.querySelectorAll('#remote-files .file-item');
|
|
items.forEach(item => item.classList.remove('selected'));
|
|
|
|
const selected = document.querySelector(`#remote-files .file-item[data-name="${name}"]`);
|
|
if (selected) {
|
|
selected.classList.add('selected');
|
|
this.selectedRemote = { name, isDirectory };
|
|
}
|
|
|
|
this.updateRemoteActions();
|
|
}
|
|
|
|
updateRemoteActions() {
|
|
const downloadBtn = document.getElementById('download-btn');
|
|
const deleteBtn = document.getElementById('delete-btn');
|
|
|
|
if (this.selectedRemote && !this.selectedRemote.isDirectory) {
|
|
downloadBtn.disabled = false;
|
|
deleteBtn.disabled = false;
|
|
} else {
|
|
downloadBtn.disabled = true;
|
|
deleteBtn.disabled = true;
|
|
}
|
|
}
|
|
|
|
navigateRemote(name) {
|
|
if (name === '..') {
|
|
this.remotePath = this.remotePath.split('/').slice(0, -1).join('/');
|
|
} else {
|
|
this.remotePath = this.remotePath ? `${this.remotePath}/${name}` : name;
|
|
}
|
|
this.refreshRemote();
|
|
}
|
|
|
|
navigateRemoteUp() {
|
|
if (this.remotePath) {
|
|
this.remotePath = this.remotePath.split('/').slice(0, -1).join('/');
|
|
this.refreshRemote();
|
|
}
|
|
}
|
|
|
|
handleLocalFileSelect(files) {
|
|
if (files.length === 0) return;
|
|
|
|
this.localFiles = Array.from(files).map(file => ({
|
|
name: file.name,
|
|
size: file.size,
|
|
file: file,
|
|
isDirectory: false
|
|
}));
|
|
|
|
this.renderLocalFiles();
|
|
}
|
|
|
|
renderLocalFiles() {
|
|
const container = document.getElementById('local-files');
|
|
container.innerHTML = '';
|
|
|
|
this.localFiles.forEach((item, index) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'file-item file';
|
|
div.dataset.index = index;
|
|
div.innerHTML = `
|
|
<span class="file-icon">${this.getFileIcon(item.name)}</span>
|
|
<span class="file-name">${item.name}</span>
|
|
<span class="file-size">${this.formatSize(item.size)}</span>
|
|
<button class="remove-btn" data-index="${index}">×</button>
|
|
`;
|
|
container.appendChild(div);
|
|
});
|
|
|
|
container.querySelectorAll('.remove-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const index = parseInt(btn.dataset.index);
|
|
this.localFiles.splice(index, 1);
|
|
this.renderLocalFiles();
|
|
this.updateLocalActions();
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll('.file-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
container.querySelectorAll('.file-item').forEach(i => i.classList.remove('selected'));
|
|
item.classList.add('selected');
|
|
this.selectedLocal = parseInt(item.dataset.index);
|
|
this.updateLocalActions();
|
|
});
|
|
});
|
|
|
|
this.updateLocalActions();
|
|
}
|
|
|
|
updateLocalActions() {
|
|
const uploadBtn = document.getElementById('upload-btn');
|
|
uploadBtn.disabled = this.localFiles.length === 0;
|
|
}
|
|
|
|
navigateLocalUp() {
|
|
this.localFiles = [];
|
|
this.selectedLocal = null;
|
|
this.renderLocalFiles();
|
|
this.updateLocalActions();
|
|
}
|
|
|
|
async uploadSelected() {
|
|
if (this.localFiles.length === 0) return;
|
|
|
|
for (const item of this.localFiles) {
|
|
await this.uploadFile(item);
|
|
}
|
|
|
|
this.localFiles = [];
|
|
this.selectedLocal = null;
|
|
this.renderLocalFiles();
|
|
this.updateLocalActions();
|
|
this.refreshRemote();
|
|
}
|
|
|
|
async uploadFile(item) {
|
|
const transferId = Date.now();
|
|
this.addTransfer(transferId, item.name, 'upload', item.size);
|
|
|
|
try {
|
|
const totalChunks = Math.ceil(item.size / this.chunkSize);
|
|
|
|
const startRes = await fetch('/api/files/upload/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
filename: item.name,
|
|
totalChunks,
|
|
fileSize: item.size
|
|
})
|
|
});
|
|
|
|
const { fileId } = await startRes.json();
|
|
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
const start = i * this.chunkSize;
|
|
const end = Math.min(start + this.chunkSize, item.size);
|
|
const chunk = item.file.slice(start, end);
|
|
|
|
const formData = new FormData();
|
|
formData.append('fileId', fileId);
|
|
formData.append('chunkIndex', i);
|
|
formData.append('chunk', chunk);
|
|
|
|
await fetch('/api/files/upload/chunk', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const progress = Math.round(((i + 1) / totalChunks) * 100);
|
|
this.updateTransfer(transferId, progress);
|
|
}
|
|
|
|
const remoteFilePath = this.remotePath ? `${this.remotePath}/${item.name}` : item.name;
|
|
|
|
await fetch('/api/files/upload/merge', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
fileId,
|
|
totalChunks,
|
|
filename: remoteFilePath
|
|
})
|
|
});
|
|
|
|
this.completeTransfer(transferId);
|
|
} catch (error) {
|
|
console.error('Upload failed:', error);
|
|
this.failTransfer(transferId, error.message);
|
|
}
|
|
}
|
|
|
|
async downloadSelected() {
|
|
if (!this.selectedRemote || this.selectedRemote.isDirectory) return;
|
|
|
|
const filename = this.selectedRemote.name;
|
|
const remoteFilePath = this.remotePath ? `${this.remotePath}/${filename}` : filename;
|
|
|
|
const transferId = Date.now();
|
|
this.addTransfer(transferId, filename, 'download', 0);
|
|
|
|
try {
|
|
const res = await fetch(`/api/files/${encodeURIComponent(remoteFilePath)}`);
|
|
|
|
if (!res.ok) {
|
|
throw new Error('Download failed');
|
|
}
|
|
|
|
const contentLength = parseInt(res.headers.get('Content-Length') || '0');
|
|
this.updateTransferSize(transferId, contentLength);
|
|
|
|
const reader = res.body.getReader();
|
|
const chunks = [];
|
|
let received = 0;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
chunks.push(value);
|
|
received += value.length;
|
|
|
|
const progress = contentLength > 0 ? Math.round((received / contentLength) * 100) : 0;
|
|
this.updateTransfer(transferId, progress);
|
|
}
|
|
|
|
const blob = new Blob(chunks);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
this.completeTransfer(transferId);
|
|
} catch (error) {
|
|
console.error('Download failed:', error);
|
|
this.failTransfer(transferId, error.message);
|
|
}
|
|
}
|
|
|
|
async deleteSelected() {
|
|
if (!this.selectedRemote || this.selectedRemote.isDirectory) return;
|
|
|
|
if (!confirm(`确定要删除 "${this.selectedRemote.name}" 吗?`)) return;
|
|
|
|
const remoteFilePath = this.remotePath ? `${this.remotePath}/${this.selectedRemote.name}` : this.selectedRemote.name;
|
|
|
|
try {
|
|
const res = await fetch(`/api/files/${encodeURIComponent(remoteFilePath)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (res.ok) {
|
|
this.refreshRemote();
|
|
}
|
|
} catch (error) {
|
|
console.error('Delete failed:', error);
|
|
}
|
|
}
|
|
|
|
addTransfer(id, name, type, size) {
|
|
const transfer = { id, name, type, size, progress: 0, status: 'transferring' };
|
|
this.transfers.push(transfer);
|
|
this.renderTransfers();
|
|
}
|
|
|
|
updateTransfer(id, progress) {
|
|
const transfer = this.transfers.find(t => t.id === id);
|
|
if (transfer) {
|
|
transfer.progress = progress;
|
|
this.renderTransfers();
|
|
}
|
|
}
|
|
|
|
updateTransferSize(id, size) {
|
|
const transfer = this.transfers.find(t => t.id === id);
|
|
if (transfer) {
|
|
transfer.size = size;
|
|
this.renderTransfers();
|
|
}
|
|
}
|
|
|
|
completeTransfer(id) {
|
|
const transfer = this.transfers.find(t => t.id === id);
|
|
if (transfer) {
|
|
transfer.status = 'completed';
|
|
transfer.progress = 100;
|
|
this.renderTransfers();
|
|
|
|
setTimeout(() => {
|
|
this.transfers = this.transfers.filter(t => t.id !== id);
|
|
this.renderTransfers();
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
failTransfer(id, error) {
|
|
const transfer = this.transfers.find(t => t.id === id);
|
|
if (transfer) {
|
|
transfer.status = 'failed';
|
|
transfer.error = error;
|
|
this.renderTransfers();
|
|
}
|
|
}
|
|
|
|
renderTransfers() {
|
|
const container = document.getElementById('transfer-list');
|
|
|
|
if (this.transfers.length === 0) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = this.transfers.map(t => `
|
|
<div class="transfer-item ${t.status}">
|
|
<span class="transfer-type">${t.type === 'upload' ? '↑' : '↓'}</span>
|
|
<span class="transfer-name">${t.name}</span>
|
|
<div class="transfer-progress">
|
|
<div class="progress-bar" style="width: ${t.progress}%"></div>
|
|
</div>
|
|
<span class="transfer-percent">${t.progress}%</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
formatSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
getFileIcon(filename) {
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
const icons = {
|
|
'txt': '📄', 'doc': '📄', 'docx': '📄', 'pdf': '📄',
|
|
'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️',
|
|
'mp3': '🎵', 'wav': '🎵', 'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬',
|
|
'zip': '📦', 'rar': '📦', '7z': '📦',
|
|
'exe': '⚙️', 'msi': '⚙️',
|
|
'js': '📜', 'ts': '📜', 'py': '📜', 'html': '📜', 'css': '📜'
|
|
};
|
|
return icons[ext] || '📄';
|
|
}
|
|
}
|
|
|
|
window.FilePanel = FilePanel;
|