Files
XCDesktop/remote/public/js/file-panel.js
2026-03-08 01:34:54 +08:00

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">&times;</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}">&times;</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;