1606 lines
55 KiB
JavaScript
1606 lines
55 KiB
JavaScript
// SFTP Manager 前端业务逻辑
|
||
|
||
// API 基础路径(使用相对路径以支持 context-path)
|
||
const API_BASE = '';
|
||
|
||
// 面板状态
|
||
const panelState = {
|
||
left: {
|
||
mode: 'local',
|
||
sessionId: 'local',
|
||
currentPath: '',
|
||
selectedFiles: []
|
||
},
|
||
right: {
|
||
mode: 'local',
|
||
sessionId: 'local',
|
||
currentPath: '',
|
||
selectedFiles: []
|
||
}
|
||
};
|
||
|
||
// 活跃连接列表 { sessionId: Connection }
|
||
let activeConnections = {};
|
||
|
||
// 当前活动面板(用于快捷键)
|
||
let activePanelId = 'left';
|
||
|
||
// 是否展示隐藏文件(默认不展示)
|
||
let showHiddenFiles = false;
|
||
|
||
// 初始化
|
||
$(document).ready(function() {
|
||
initApp();
|
||
});
|
||
|
||
// 应用初始化
|
||
function initApp() {
|
||
// 获取初始路径并加载文件
|
||
initPanelPath('left');
|
||
initPanelPath('right');
|
||
|
||
// 加载活跃连接
|
||
loadActiveConnections();
|
||
|
||
// 绑定键盘事件
|
||
bindKeyboardEvents();
|
||
|
||
// 默认高亮左侧面板
|
||
setActivePanel('left');
|
||
|
||
// 文件项拖拽(跨面板传输)
|
||
initDragAndDrop();
|
||
|
||
// 面板点击切换活动面板
|
||
$('#left-panel').on('click', function() { setActivePanel('left'); });
|
||
$('#right-panel').on('click', function() { setActivePanel('right'); });
|
||
}
|
||
|
||
// 设置当前活动面板并应用高亮样式
|
||
function setActivePanel(panelId) {
|
||
activePanelId = panelId;
|
||
$('#left-panel, #right-panel').removeClass('active-panel');
|
||
$('#' + panelId + '-panel').addClass('active-panel');
|
||
}
|
||
|
||
// 初始化面板路径
|
||
function initPanelPath(panelId) {
|
||
const sessionId = panelState[panelId].sessionId;
|
||
updateStatus(`正在加载${panelId === 'left' ? '左' : '右'}面板...`);
|
||
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/path',
|
||
method: 'GET',
|
||
data: { sessionId: sessionId },
|
||
success: function(response) {
|
||
if (response.success && response.data && response.data.path) {
|
||
panelState[panelId].currentPath = response.data.path;
|
||
loadFiles(panelId);
|
||
} else {
|
||
$(`#${panelId}-file-list`).html('<div class="text-center text-muted p-3">无法获取路径</div>');
|
||
updateStatus('就绪');
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
$(`#${panelId}-file-list`).html('<div class="text-center text-danger p-3">加载失败: ' + error + '</div>');
|
||
updateStatus('错误: ' + error);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 加载文件列表
|
||
function loadFiles(panelId) {
|
||
const sessionId = panelState[panelId].sessionId;
|
||
const path = panelState[panelId].currentPath;
|
||
|
||
if (!path) {
|
||
initPanelPath(panelId);
|
||
return;
|
||
}
|
||
|
||
updateStatus(`正在加载 ${panelId === 'left' ? '左' : '右'}面板文件列表...`);
|
||
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/list',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sessionId: sessionId,
|
||
path: path,
|
||
showHidden: showHiddenFiles
|
||
}),
|
||
success: function(response) {
|
||
if (response.success) {
|
||
renderFileList(panelId, response.data);
|
||
updatePathInput(panelId, path);
|
||
updateStatus('就绪');
|
||
} else {
|
||
$(`#${panelId}-file-list`).html('<div class="text-center text-danger p-3">' + (response.message || '加载失败') + '</div>');
|
||
updateStatus('错误: ' + (response.message || '加载失败'));
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
|
||
$(`#${panelId}-file-list`).html('<div class="text-center text-danger p-3">加载失败: ' + errMsg + '</div>');
|
||
updateStatus('错误: ' + errMsg);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 排序文件列表:文件夹在前,同类型按名称(不区分大小写)
|
||
function sortFileList(files) {
|
||
if (!Array.isArray(files) || files.length === 0) return files;
|
||
return files.slice().sort(function(a, b) {
|
||
const aDir = a.isDirectory === true || a.isDirectory === 'true' || a.directory === true || a.directory === 'true';
|
||
const bDir = b.isDirectory === true || b.isDirectory === 'true' || b.directory === true || b.directory === 'true';
|
||
if (aDir !== bDir) return aDir ? -1 : 1;
|
||
const nameA = (a.name || '').toLowerCase();
|
||
const nameB = (b.name || '').toLowerCase();
|
||
return nameA.localeCompare(nameB, 'zh-CN', { sensitivity: 'base' });
|
||
});
|
||
}
|
||
|
||
// 渲染文件列表(仅当 data 为数组时渲染,避免错误数据结构被当成文件显示)
|
||
function renderFileList(panelId, files) {
|
||
const fileList = $(`#${panelId}-file-list`);
|
||
fileList.empty();
|
||
|
||
if (!Array.isArray(files)) {
|
||
fileList.html('<div class="text-center text-danger p-3">数据格式错误,请刷新重试</div>');
|
||
return;
|
||
}
|
||
if (files.length === 0) {
|
||
fileList.html('<div class="text-center text-muted p-3">文件夹为空</div>');
|
||
return;
|
||
}
|
||
|
||
files = sortFileList(files);
|
||
|
||
files.forEach(function(file) {
|
||
// 确保 isDirectory 字段存在且为布尔值
|
||
// 兼容多种可能的字段名和值类型
|
||
const isDir = file.isDirectory === true ||
|
||
file.isDirectory === 'true' ||
|
||
file.directory === true ||
|
||
file.directory === 'true';
|
||
|
||
const icon = getFileIcon(file.name, isDir);
|
||
const size = isDir ? '' : formatFileSize(file.size);
|
||
const date = formatDate(file.modifiedTime);
|
||
const escapedPath = $('<div>').text(file.path).html();
|
||
const escapedName = $('<div>').text(file.name).html();
|
||
|
||
const item = $('<div class="file-item" data-path="' + escapedPath + '" data-is-dir="' + isDir + '">' +
|
||
'<span class="file-icon">' + icon + '</span>' +
|
||
'<span class="file-name" title="' + escapedName + '">' + escapedName + '</span>' +
|
||
'<span class="file-size">' + size + '</span>' +
|
||
'<span class="file-date">' + date + '</span>' +
|
||
'</div>');
|
||
|
||
// 单击选择
|
||
item.on('click', function(e) {
|
||
if (!e.ctrlKey) {
|
||
fileList.find('.file-item').removeClass('selected');
|
||
}
|
||
item.toggleClass('selected');
|
||
updateSelectedFiles(panelId);
|
||
});
|
||
|
||
// 双击进入目录
|
||
item.on('dblclick', function(e) {
|
||
e.stopPropagation();
|
||
const itemPath = $(this).data('path');
|
||
const itemIsDir = $(this).data('is-dir');
|
||
// 处理布尔值和字符串形式的布尔值
|
||
const isDirectory = itemIsDir === true || itemIsDir === 'true' || itemIsDir === 'True';
|
||
if (isDirectory && itemPath) {
|
||
enterDirectory(panelId, itemPath);
|
||
}
|
||
});
|
||
|
||
// 右键菜单
|
||
item.on('contextmenu', function(e) {
|
||
e.preventDefault();
|
||
const itemPath = $(this).data('path');
|
||
const itemIsDir = $(this).data('is-dir');
|
||
if (itemPath) showContextMenu(e, panelId, itemPath, itemIsDir);
|
||
});
|
||
|
||
item.attr('draggable', true);
|
||
fileList.append(item);
|
||
});
|
||
}
|
||
|
||
// 获取文件图标
|
||
function getFileIcon(fileName, isDirectory) {
|
||
if (isDirectory) {
|
||
return '<span class="file-type file-type-dir">DIR</span>';
|
||
}
|
||
|
||
const ext = (fileName.substring(fileName.lastIndexOf('.') + 1) || '').toLowerCase();
|
||
const typeMap = {
|
||
'txt': 'TXT', 'md': 'TXT',
|
||
'doc': 'DOC', 'docx': 'DOC',
|
||
'xls': 'XLS', 'xlsx': 'XLS',
|
||
'ppt': 'PPT', 'pptx': 'PPT',
|
||
'pdf': 'PDF',
|
||
'jpg': 'IMG', 'jpeg': 'IMG', 'png': 'IMG', 'gif': 'IMG', 'webp': 'IMG',
|
||
'mp4': 'VID', 'avi': 'VID', 'mkv': 'VID', 'mov': 'VID',
|
||
'mp3': 'AUD', 'wav': 'AUD', 'flac': 'AUD',
|
||
'zip': 'ZIP', 'rar': 'ZIP', '7z': 'ZIP',
|
||
'java': 'CODE', 'js': 'CODE', 'ts': 'CODE', 'py': 'CODE', 'php': 'CODE', 'go': 'CODE',
|
||
'html': 'WEB', 'css': 'WEB',
|
||
'json': 'JSON', 'yml': 'YML', 'yaml': 'YML'
|
||
};
|
||
const label = typeMap[ext] || (ext ? ext.toUpperCase().slice(0, 4) : 'FILE');
|
||
return '<span class="file-type">' + label + '</span>';
|
||
}
|
||
|
||
// 格式化文件大小
|
||
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 (Math.round(bytes / Math.pow(k, i) * 100) / 100) + ' ' + sizes[i];
|
||
}
|
||
|
||
// 格式化日期
|
||
function formatDate(dateString) {
|
||
if (!dateString) return '';
|
||
const date = new Date(dateString);
|
||
return date.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
// 进入目录
|
||
function enterDirectory(panelId, path) {
|
||
panelState[panelId].currentPath = path;
|
||
loadFiles(panelId);
|
||
}
|
||
|
||
// 返回上级目录
|
||
function goUp(panelId) {
|
||
const currentPath = panelState[panelId].currentPath;
|
||
const parentPath = getParentPath(currentPath);
|
||
|
||
if (parentPath && parentPath !== currentPath) {
|
||
panelState[panelId].currentPath = parentPath;
|
||
loadFiles(panelId);
|
||
} else {
|
||
updateStatus('已是根目录');
|
||
}
|
||
}
|
||
|
||
// 获取父路径(兼容 Windows 和 Unix)
|
||
function getParentPath(path) {
|
||
if (!path) return null;
|
||
path = path.replace(/\/$/, '');
|
||
const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
||
if (idx <= 0) return path;
|
||
return path.substring(0, idx) || path.substring(0, idx + 1);
|
||
}
|
||
|
||
// 更新选中文件列表
|
||
function updateSelectedFiles(panelId) {
|
||
const selected = $(`#${panelId}-file-list .file-item.selected`);
|
||
panelState[panelId].selectedFiles = selected.map(function() {
|
||
return {
|
||
path: $(this).data('path'),
|
||
isDirectory: $(this).data('is-dir') === true ||
|
||
$(this).data('is-dir') === 'true' ||
|
||
$(this).data('is-dir') === 'True'
|
||
};
|
||
}).get();
|
||
}
|
||
|
||
// 全选
|
||
function selectAll(panelId) {
|
||
$(`#${panelId}-file-list .file-item`).addClass('selected');
|
||
updateSelectedFiles(panelId);
|
||
}
|
||
|
||
// 取消选择
|
||
function deselectAll(panelId) {
|
||
$(`#${panelId}-file-list .file-item`).removeClass('selected');
|
||
updateSelectedFiles(panelId);
|
||
}
|
||
|
||
// 反选
|
||
function invertSelection(panelId) {
|
||
$(`#${panelId}-file-list .file-item`).toggleClass('selected');
|
||
updateSelectedFiles(panelId);
|
||
}
|
||
|
||
// 文件项拖拽初始化(跨面板拖放传输)
|
||
function initDragAndDrop() {
|
||
$(document).on('dragstart', '.file-item', function(e) {
|
||
const panelId = $(this).closest('.panel').attr('id').replace('-panel', '');
|
||
const path = $(this).data('path');
|
||
e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
|
||
panelId: panelId,
|
||
path: path
|
||
}));
|
||
e.originalEvent.dataTransfer.effectAllowed = 'copyMove';
|
||
$(this).addClass('dragging');
|
||
});
|
||
|
||
$(document).on('dragend', '.file-item', function() {
|
||
$(this).removeClass('dragging');
|
||
});
|
||
|
||
$('#left-file-list, #right-file-list').on('dragover', function(e) {
|
||
e.preventDefault();
|
||
e.originalEvent.dataTransfer.dropEffect = 'copy';
|
||
$(this).addClass('drag-over');
|
||
});
|
||
|
||
$('#left-file-list, #right-file-list').on('dragleave', function(e) {
|
||
if (!$(this).find(e.relatedTarget).length) {
|
||
$(this).removeClass('drag-over');
|
||
}
|
||
});
|
||
|
||
$('#left-file-list, #right-file-list').on('drop', function(e) {
|
||
e.preventDefault();
|
||
$(this).removeClass('drag-over');
|
||
try {
|
||
const data = JSON.parse(e.originalEvent.dataTransfer.getData('text/plain'));
|
||
const targetPanelId = $(this).closest('.panel').attr('id').replace('-panel', '');
|
||
handleFileDrop(data, targetPanelId);
|
||
} catch (err) {
|
||
console.error('拖拽失败:', err);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 显示跨面板传输进度(按文件个数)
|
||
function showTransferCountProgress(current, total, fileName) {
|
||
const label = total > 1
|
||
? '传输中 (' + current + '/' + total + ')' + (fileName ? ' ' + fileName : '')
|
||
: '传输中 ' + (fileName || '');
|
||
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
||
showTransferProgress(true, label);
|
||
updateTransferProgress(percent, label);
|
||
}
|
||
|
||
// 处理文件拖放(同面板移动 / 跨面板传输)
|
||
function handleFileDrop(data, targetPanelId) {
|
||
const sourcePanelId = data.panelId;
|
||
const sourcePath = data.path;
|
||
const targetPath = panelState[targetPanelId].currentPath;
|
||
|
||
if (sourcePanelId === targetPanelId) {
|
||
updateStatus('同面板内移动功能开发中,请使用「传输到右侧」或「传输到左侧」按钮');
|
||
return;
|
||
}
|
||
|
||
const sourceSessionId = panelState[sourcePanelId].sessionId;
|
||
const targetSessionId = panelState[targetPanelId].sessionId;
|
||
const fileName = getFileNameFromPath(sourcePath);
|
||
|
||
showTransferCountProgress(0, 1, fileName);
|
||
updateTransferProgress(0, '传输中 ' + fileName, true); // 拖拽跨面板传输,后端无流式进度
|
||
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/transfer',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sourceSessionId: sourceSessionId,
|
||
sourcePaths: [sourcePath],
|
||
targetSessionId: targetSessionId,
|
||
targetPath: targetPath,
|
||
recursive: true
|
||
}),
|
||
success: function(response) {
|
||
showTransferProgress(false);
|
||
if (response.success) {
|
||
updateStatus('传输成功');
|
||
loadFiles(targetPanelId);
|
||
} else {
|
||
alert('传输失败: ' + (response.message || '未知错误'));
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
showTransferProgress(false);
|
||
const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
|
||
alert('传输失败: ' + errMsg);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 更新路径输入框
|
||
function updatePathInput(panelId, path) {
|
||
$(`#${panelId}-path`).val(path || '');
|
||
}
|
||
|
||
// 更新状态栏
|
||
function updateStatus(text) {
|
||
$('#status-text').text(text || '就绪');
|
||
}
|
||
|
||
// 模式切换(本地 / SFTP)
|
||
function onModeChange(panelId) {
|
||
const select = $(`#${panelId}-mode`);
|
||
const connSelect = $(`#${panelId}-connection`);
|
||
const statusDiv = $(`#${panelId}-status`);
|
||
const mode = select.val();
|
||
|
||
panelState[panelId].mode = mode;
|
||
panelState[panelId].selectedFiles = [];
|
||
|
||
if (mode === 'local') {
|
||
panelState[panelId].sessionId = 'local';
|
||
connSelect.hide();
|
||
statusDiv.hide();
|
||
initPanelPath(panelId);
|
||
updateStatus('已切换到本地模式');
|
||
} else {
|
||
connSelect.show();
|
||
updateConnectionSelect(panelId);
|
||
const sessionId = connSelect.val();
|
||
if (sessionId) {
|
||
panelState[panelId].sessionId = sessionId;
|
||
initPanelPath(panelId);
|
||
showConnectionStatus(panelId, sessionId);
|
||
updateStatus('已切换到SFTP模式');
|
||
} else {
|
||
$(`#${panelId}-file-list`).html('<div class="text-center text-muted p-3">请选择SFTP连接或从连接管理中连接</div>');
|
||
showConnectionStatus(panelId, null);
|
||
updateStatus('请选择SFTP连接');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 连接切换
|
||
function onConnectionChange(panelId) {
|
||
const sessionId = $(`#${panelId}-connection`).val();
|
||
panelState[panelId].sessionId = sessionId || 'local';
|
||
if (sessionId) {
|
||
initPanelPath(panelId);
|
||
showConnectionStatus(panelId, sessionId);
|
||
} else {
|
||
$(`#${panelId}-file-list`).html('<div class="text-center text-muted p-3">请选择SFTP连接</div>');
|
||
showConnectionStatus(panelId, null);
|
||
}
|
||
}
|
||
|
||
// 更新连接状态显示(状态:connected / disconnected / connecting)
|
||
function updateConnectionStatus(panelId, status, text) {
|
||
const statusDiv = $(`#${panelId}-status`);
|
||
if (statusDiv.length === 0) return;
|
||
|
||
const statusDot = statusDiv.find('.status-dot');
|
||
const statusText = statusDiv.find('.status-text');
|
||
|
||
statusDiv.removeClass('connected disconnected connecting').addClass(status);
|
||
statusDot.attr('data-status', status);
|
||
statusText.text(text || '');
|
||
}
|
||
|
||
// 根据 sessionId 显示/隐藏连接状态
|
||
function showConnectionStatus(panelId, sessionId) {
|
||
const statusDiv = $(`#${panelId}-status`);
|
||
if (statusDiv.length === 0) return;
|
||
|
||
if (panelState[panelId].mode === 'local') {
|
||
statusDiv.hide();
|
||
return;
|
||
}
|
||
|
||
statusDiv.show();
|
||
if (sessionId && activeConnections[sessionId]) {
|
||
const conn = activeConnections[sessionId];
|
||
// 仅显示「已连接」,具体连接名由下拉框展示,避免与下拉框重复显示 IP/名称
|
||
updateConnectionStatus(panelId, 'connected', '已连接');
|
||
} else {
|
||
updateConnectionStatus(panelId, 'disconnected', '未连接');
|
||
}
|
||
}
|
||
|
||
// 加载活跃连接
|
||
function loadActiveConnections() {
|
||
$.ajax({
|
||
url: API_BASE + 'api/connection/active',
|
||
method: 'GET',
|
||
success: function(response) {
|
||
if (response.success && response.data) {
|
||
activeConnections = response.data;
|
||
updateConnectionSelect('left');
|
||
updateConnectionSelect('right');
|
||
['left', 'right'].forEach(function(panelId) {
|
||
if (panelState[panelId].mode === 'sftp') {
|
||
showConnectionStatus(panelId, panelState[panelId].sessionId);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 更新连接下拉框(优先显示连接名或主机,避免暴露 sftp-uuid)
|
||
// 重建选项后会恢复当前面板的选中值,保证左右面板可独立选择不同 SFTP 连接
|
||
function updateConnectionSelect(panelId) {
|
||
const select = $(`#${panelId}-connection`);
|
||
const currentSessionId = panelState[panelId] && panelState[panelId].sessionId;
|
||
|
||
select.empty().append('<option value="">选择连接</option>');
|
||
|
||
// 为避免同一主机/端口/用户名的连接在下拉框中重复显示,
|
||
// 这里按「username@host:port」进行去重,只保留第一条会话记录
|
||
const seenKeys = new Set();
|
||
|
||
$.each(activeConnections, function(sessionId, conn) {
|
||
if (!conn) {
|
||
return;
|
||
}
|
||
|
||
const dedupKey = (conn.username || '') + '@' +
|
||
(conn.host || '') + ':' + (conn.port || 22);
|
||
if (seenKeys.has(dedupKey)) {
|
||
return; // 已有同一连接信息,跳过重复项
|
||
}
|
||
seenKeys.add(dedupKey);
|
||
|
||
const name = (conn && (conn.name || conn.host)) ? (conn.name || conn.host) : ('会话 ' + (sessionId || '').substring(0, 13));
|
||
select.append('<option value="' + sessionId + '">' + $('<div>').text(name).html() + '</option>');
|
||
});
|
||
|
||
// 恢复当前面板的选中连接,避免左右两侧被同步成同一个
|
||
if (currentSessionId && activeConnections[currentSessionId]) {
|
||
select.val(currentSessionId);
|
||
} else {
|
||
select.val('');
|
||
}
|
||
}
|
||
|
||
// 刷新面板(先更新活跃连接,再加载左右面板文件,避免竞态和错误数据)
|
||
function refreshPanels() {
|
||
$.ajax({
|
||
url: API_BASE + 'api/connection/active',
|
||
method: 'GET',
|
||
success: function(response) {
|
||
if (response.success && response.data) {
|
||
activeConnections = response.data;
|
||
updateConnectionSelect('left');
|
||
updateConnectionSelect('right');
|
||
['left', 'right'].forEach(function(panelId) {
|
||
if (panelState[panelId].mode === 'sftp') {
|
||
showConnectionStatus(panelId, panelState[panelId].sessionId);
|
||
}
|
||
});
|
||
}
|
||
loadFiles('left');
|
||
loadFiles('right');
|
||
updateStatus('已刷新');
|
||
},
|
||
error: function() {
|
||
loadFiles('left');
|
||
loadFiles('right');
|
||
updateStatus('已刷新');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 切换是否展示隐藏文件
|
||
function toggleShowHidden() {
|
||
showHiddenFiles = !showHiddenFiles;
|
||
const btn = $('#btn-show-hidden');
|
||
if (showHiddenFiles) {
|
||
btn.removeClass('btn-outline-secondary').addClass('btn-secondary');
|
||
btn.text('隐藏文件已显示');
|
||
} else {
|
||
btn.removeClass('btn-secondary').addClass('btn-outline-secondary');
|
||
btn.text('显示隐藏文件');
|
||
}
|
||
loadFiles('left');
|
||
loadFiles('right');
|
||
updateStatus(showHiddenFiles ? '已显示隐藏文件' : '已隐藏隐藏文件');
|
||
}
|
||
|
||
// 键盘事件
|
||
function bindKeyboardEvents() {
|
||
$(document).on('keydown', function(e) {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
|
||
if (e.key === 'Backspace') {
|
||
goUp(activePanelId);
|
||
e.preventDefault();
|
||
}
|
||
if (e.key === 'F5') {
|
||
refreshPanels();
|
||
e.preventDefault();
|
||
}
|
||
if (e.key === 'Escape') {
|
||
deselectAll(activePanelId);
|
||
e.preventDefault();
|
||
}
|
||
// Ctrl+A: 全选
|
||
if (e.ctrlKey && (e.key === 'a' || e.key === 'A')) {
|
||
selectAll(activePanelId);
|
||
e.preventDefault();
|
||
}
|
||
if (e.key === 'Delete') {
|
||
deleteSelectedFiles(activePanelId);
|
||
e.preventDefault();
|
||
}
|
||
if (e.key === 'F2') {
|
||
showRenameDialog();
|
||
e.preventDefault();
|
||
}
|
||
// Ctrl+Shift+N: 新建文件夹
|
||
if (e.ctrlKey && e.shiftKey && e.key === 'N') {
|
||
showMkdirDialog(activePanelId);
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 获取当前活动面板ID
|
||
function getActivePanelId() {
|
||
return activePanelId;
|
||
}
|
||
|
||
// 刷新当前面板(删除后刷新列表)
|
||
function refreshCurrentPanel() {
|
||
loadFiles(activePanelId);
|
||
}
|
||
|
||
// 从路径获取文件名
|
||
function getFileNameFromPath(path) {
|
||
if (!path) return '';
|
||
const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
||
if (idx === -1) return path;
|
||
return path.substring(idx + 1);
|
||
}
|
||
|
||
// ========== 连接管理(模块03)==========
|
||
function ensureBootstrap() {
|
||
if (typeof bootstrap === 'undefined') {
|
||
alert('Bootstrap 未加载,请检查网络连接或暂时关闭广告拦截器后刷新页面');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function showConnectionDialog() {
|
||
loadConnectionList();
|
||
if (!ensureBootstrap()) return;
|
||
new bootstrap.Modal(document.getElementById('connectionModal')).show();
|
||
}
|
||
|
||
function loadConnectionList() {
|
||
$.ajax({
|
||
url: API_BASE + 'api/connection/list',
|
||
method: 'GET',
|
||
success: function(response) {
|
||
const list = $('#connection-list');
|
||
list.empty();
|
||
if (response.success && response.data && response.data.length > 0) {
|
||
response.data.forEach(function(conn) {
|
||
const activeSession = findSessionByConnectionId(conn.id);
|
||
const connName = $('<div>').text(conn.name || '').html();
|
||
const connInfo = (conn.host || '') + ':' + (conn.port || 22);
|
||
let actions = '';
|
||
if (activeSession) {
|
||
const sessionId = activeSession[0];
|
||
actions = '<button class="btn btn-sm btn-warning" onclick="disconnectFromSftp(\'' + sessionId + '\')">断开</button>' +
|
||
'<button class="btn btn-sm btn-danger" onclick="deleteConnection(' + conn.id + ')">删除</button>';
|
||
} else {
|
||
actions = '<button class="btn btn-sm btn-primary" onclick="connectToServer(' + conn.id + ')">连接</button>' +
|
||
'<button class="btn btn-sm btn-danger" onclick="deleteConnection(' + conn.id + ')">删除</button>';
|
||
}
|
||
list.append(
|
||
'<div class="connection-item" data-id="' + conn.id + '">' +
|
||
'<div><span class="connection-name">' + connName + '</span>' +
|
||
'<div class="connection-info">' + connInfo + '</div></div>' +
|
||
'<div class="connection-actions">' + actions + '</div></div>'
|
||
);
|
||
});
|
||
} else {
|
||
list.html('<div class="text-muted p-3">暂无已保存的连接,请添加连接</div>');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 根据连接 ID 查找活跃会话 [sessionId, conn]
|
||
function findSessionByConnectionId(connectionId) {
|
||
const id = Number(connectionId);
|
||
const entries = Object.entries(activeConnections || {});
|
||
for (let i = 0; i < entries.length; i++) {
|
||
const sessionId = entries[i][0];
|
||
const conn = entries[i][1];
|
||
if (conn && (conn.id === id || conn.id === connectionId)) {
|
||
return [sessionId, conn];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 删除已保存的连接
|
||
function deleteConnection(id) {
|
||
if (!confirm('确定要删除此连接吗?')) return;
|
||
$.ajax({
|
||
url: API_BASE + 'api/connection/' + id,
|
||
method: 'DELETE',
|
||
success: function(response) {
|
||
if (response.success) {
|
||
updateStatus('删除成功');
|
||
loadConnectionList();
|
||
loadActiveConnections();
|
||
updateConnectionSelect('left');
|
||
updateConnectionSelect('right');
|
||
} else {
|
||
alert('删除失败: ' + (response.message || '未知错误'));
|
||
}
|
||
},
|
||
error: function(xhr) {
|
||
alert('删除失败: ' + (xhr.responseJSON && xhr.responseJSON.message || xhr.statusText));
|
||
}
|
||
});
|
||
}
|
||
|
||
// 断开 SFTP 连接
|
||
function disconnectFromSftp(sessionId) {
|
||
updateStatus('正在断开连接...');
|
||
$.ajax({
|
||
url: API_BASE + 'api/connection/disconnect',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({ sessionId: sessionId }),
|
||
success: function(response) {
|
||
if (response.success) {
|
||
delete activeConnections[sessionId];
|
||
updatePanelsAfterDisconnect(sessionId);
|
||
updateConnectionSelect('left');
|
||
updateConnectionSelect('right');
|
||
['left', 'right'].forEach(function(panelId) {
|
||
showConnectionStatus(panelId, panelState[panelId].sessionId);
|
||
});
|
||
loadConnectionList();
|
||
updateStatus('已断开连接');
|
||
} else {
|
||
alert('断开失败: ' + (response.message || '未知错误'));
|
||
}
|
||
},
|
||
error: function(xhr) {
|
||
alert('断开失败: ' + (xhr.responseJSON && xhr.responseJSON.message || xhr.statusText));
|
||
}
|
||
});
|
||
}
|
||
|
||
// 断开连接后更新使用该会话的面板为本地模式
|
||
function updatePanelsAfterDisconnect(sessionId) {
|
||
['left', 'right'].forEach(function(panelId) {
|
||
if (panelState[panelId].sessionId === sessionId) {
|
||
panelState[panelId].sessionId = 'local';
|
||
panelState[panelId].mode = 'local';
|
||
panelState[panelId].currentPath = '';
|
||
$('#' + panelId + '-mode').val('local');
|
||
$('#' + panelId + '-connection').hide().val('');
|
||
$('#' + panelId + '-status').hide();
|
||
initPanelPath(panelId);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 连接成功后只更新当前活动面板为新连接,另一侧保持原选择,便于左右选不同 SFTP
|
||
function updatePanelStateWithConnection(sessionId, conn) {
|
||
const panelId = activePanelId;
|
||
if (panelState[panelId].mode === 'sftp') {
|
||
panelState[panelId].sessionId = sessionId;
|
||
$('#' + panelId + '-connection').val(sessionId);
|
||
initPanelPath(panelId);
|
||
showConnectionStatus(panelId, sessionId);
|
||
}
|
||
// 另一侧保持原有连接选择不变
|
||
}
|
||
|
||
function showAddConnectionDialog() {
|
||
document.getElementById('connection-form').reset();
|
||
if (!ensureBootstrap()) return;
|
||
new bootstrap.Modal(document.getElementById('addConnectionModal')).show();
|
||
}
|
||
|
||
function saveConnection() {
|
||
const form = document.getElementById('connection-form');
|
||
const data = {
|
||
name: form.name.value,
|
||
host: form.host.value,
|
||
port: parseInt(form.port.value, 10) || 22,
|
||
username: form.username.value,
|
||
password: form.password.value || null,
|
||
privateKeyPath: form.privateKeyPath.value || null,
|
||
passPhrase: form.passPhrase.value || null
|
||
};
|
||
|
||
$.ajax({
|
||
url: API_BASE + 'api/connection/save',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify(data),
|
||
success: function(response) {
|
||
if (response.success) {
|
||
if (typeof bootstrap !== 'undefined') {
|
||
bootstrap.Modal.getInstance(document.getElementById('addConnectionModal')).hide();
|
||
}
|
||
loadConnectionList();
|
||
} else {
|
||
alert('保存失败: ' + (response.message || '未知错误'));
|
||
}
|
||
},
|
||
error: function(xhr) {
|
||
alert('保存失败: ' + (xhr.responseJSON && xhr.responseJSON.message || xhr.statusText));
|
||
}
|
||
});
|
||
}
|
||
|
||
function connectToServer(connId) {
|
||
$.ajax({
|
||
url: API_BASE + 'api/connection/' + connId,
|
||
method: 'GET',
|
||
success: function(response) {
|
||
if (response.success && response.data) {
|
||
const conn = response.data;
|
||
if (findSessionByConnectionId(conn.id)) {
|
||
alert('该连接已在使用中');
|
||
return;
|
||
}
|
||
updateStatus('正在连接 ' + (conn.name || conn.host) + '...');
|
||
$.ajax({
|
||
url: API_BASE + 'api/connection/connect',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
id: conn.id,
|
||
host: conn.host,
|
||
port: conn.port,
|
||
username: conn.username
|
||
}),
|
||
success: function(res) {
|
||
if (res.success) {
|
||
const sessionId = res.data;
|
||
activeConnections[sessionId] = conn;
|
||
updateConnectionSelect('left');
|
||
updateConnectionSelect('right');
|
||
updatePanelStateWithConnection(sessionId, conn);
|
||
loadConnectionList();
|
||
if (typeof bootstrap !== 'undefined') {
|
||
bootstrap.Modal.getInstance(document.getElementById('connectionModal')).hide();
|
||
}
|
||
updateStatus('已连接到 ' + (conn.name || conn.host));
|
||
} else {
|
||
alert('连接失败: ' + (res.message || '未知错误'));
|
||
updateStatus('连接失败');
|
||
}
|
||
},
|
||
error: function(xhr) {
|
||
alert('连接失败: ' + (xhr.responseJSON && xhr.responseJSON.message || xhr.statusText));
|
||
updateStatus('连接失败');
|
||
}
|
||
});
|
||
} else {
|
||
alert('连接不存在');
|
||
}
|
||
},
|
||
error: function(xhr) {
|
||
alert('获取连接失败: ' + (xhr.responseJSON && xhr.responseJSON.message || xhr.statusText));
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== 文件上传下载功能(模块05)==========
|
||
|
||
// 获取目标面板ID(当前活动面板)
|
||
function getTargetPanelId() {
|
||
return activePanelId;
|
||
}
|
||
|
||
// 获取源面板ID(当前活动面板)
|
||
function getSourcePanelId() {
|
||
return activePanelId;
|
||
}
|
||
|
||
// 上传文件
|
||
function uploadFiles() {
|
||
document.getElementById('file-input').click();
|
||
}
|
||
|
||
// 初始化文件上传功能
|
||
$(document).ready(function() {
|
||
// 文件选择
|
||
$('#file-input').on('change', function(e) {
|
||
const files = e.target.files;
|
||
if (files && files.length > 0) {
|
||
uploadFilesToTarget(files);
|
||
}
|
||
// 清空input,以便可以重复选择同一文件
|
||
$(this).val('');
|
||
});
|
||
|
||
// 拖拽上传 - 为每个面板添加拖拽支持
|
||
['left', 'right'].forEach(function(panelId) {
|
||
const panel = $(`#${panelId}-panel`);
|
||
|
||
panel.on('dragover', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
$(this).addClass('drag-over');
|
||
});
|
||
|
||
panel.on('dragleave', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
$(this).removeClass('drag-over');
|
||
});
|
||
|
||
panel.on('drop', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
$(this).removeClass('drag-over');
|
||
|
||
const files = e.originalEvent.dataTransfer.files;
|
||
if (files && files.length > 0) {
|
||
activePanelId = panelId;
|
||
uploadFilesToTarget(files);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// 上传文件到目标面板
|
||
function uploadFilesToTarget(files) {
|
||
const targetPanelId = getTargetPanelId();
|
||
const targetSessionId = panelState[targetPanelId].sessionId;
|
||
const targetPath = panelState[targetPanelId].currentPath;
|
||
|
||
if (!targetPath) {
|
||
alert('请先选择目标目录');
|
||
return;
|
||
}
|
||
|
||
Array.from(files).forEach(function(file) {
|
||
uploadSingleFile(file, targetSessionId, targetPath, targetPanelId);
|
||
});
|
||
}
|
||
|
||
// 上传单个文件
|
||
function uploadSingleFile(file, targetSessionId, targetPath, targetPanelId) {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('targetSessionId', targetSessionId);
|
||
formData.append('targetPath', targetPath);
|
||
|
||
showTransferProgress(true, '上传: ' + file.name + ' 0%');
|
||
updateTransferProgress(0, '上传: ' + file.name + ' 0%');
|
||
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/upload',
|
||
method: 'POST',
|
||
data: formData,
|
||
processData: false,
|
||
contentType: false,
|
||
xhr: function() {
|
||
const xhr = new window.XMLHttpRequest();
|
||
xhr.upload.addEventListener('progress', function(e) {
|
||
if (e.lengthComputable) {
|
||
const percent = Math.round((e.loaded / e.total) * 100);
|
||
updateTransferProgress(percent, '上传: ' + file.name + ' ' + percent + '%');
|
||
}
|
||
});
|
||
return xhr;
|
||
},
|
||
success: function(response) {
|
||
if (response.success) {
|
||
updateStatus(file.name + ' 上传成功');
|
||
loadFiles(targetPanelId);
|
||
} else {
|
||
alert('上传失败: ' + (response.message || '未知错误'));
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
|
||
alert('上传失败: ' + errMsg);
|
||
},
|
||
complete: function() {
|
||
showTransferProgress(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 显示/隐藏传输进度区域(上传、下载、跨面板传输共用)
|
||
function showTransferProgress(show, label) {
|
||
const progressDiv = $('#transfer-progress');
|
||
progressDiv.css('display', show ? 'inline-flex' : 'none');
|
||
if (label !== undefined) {
|
||
$('#transfer-progress-label').text(label || '');
|
||
}
|
||
}
|
||
|
||
// 更新传输进度条与标签
|
||
// indeterminate: 当后端无流式进度时(如跨面板传输 API),设为 true 显示动画进度条
|
||
function updateTransferProgress(percent, label, indeterminate) {
|
||
const progressBar = $('#transfer-progress-bar');
|
||
if (indeterminate) {
|
||
progressBar.css('width', '100%');
|
||
progressBar.text('');
|
||
progressBar.addClass('progress-bar-striped progress-bar-animated');
|
||
} else {
|
||
progressBar.removeClass('progress-bar-striped progress-bar-animated');
|
||
progressBar.css('width', percent + '%');
|
||
progressBar.text(percent + '%');
|
||
}
|
||
if (label !== undefined) {
|
||
$('#transfer-progress-label').text(label);
|
||
}
|
||
}
|
||
|
||
// 显示上传进度(兼容旧调用)
|
||
function showUploadProgress(show) {
|
||
showTransferProgress(show);
|
||
}
|
||
|
||
// 更新上传进度(带文件名)
|
||
function updateUploadProgress(percent, fileName) {
|
||
const label = fileName ? '上传: ' + fileName + ' ' + percent + '%' : percent + '%';
|
||
updateTransferProgress(percent, label);
|
||
}
|
||
|
||
// 下载文件(带进度,使用 XHR)
|
||
function downloadFile(sessionId, path) {
|
||
const url = API_BASE + 'api/files/download?sessionId=' +
|
||
encodeURIComponent(sessionId) +
|
||
'&path=' + encodeURIComponent(path);
|
||
const fileName = getFileNameFromPath(path) || '下载';
|
||
|
||
showTransferProgress(true, '下载: ' + fileName + ' 0%');
|
||
updateTransferProgress(0, '下载: ' + fileName + ' 0%');
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('GET', url, true);
|
||
xhr.responseType = 'arraybuffer';
|
||
|
||
xhr.addEventListener('progress', function(e) {
|
||
if (e.lengthComputable && e.total > 0) {
|
||
const percent = Math.round((e.loaded / e.total) * 100);
|
||
updateTransferProgress(percent, '下载: ' + fileName + ' ' + percent + '%');
|
||
} else if (e.loaded > 0) {
|
||
const kb = (e.loaded / 1024).toFixed(1);
|
||
updateTransferProgress(0, '下载: ' + fileName + ' 已接收 ' + kb + ' KB');
|
||
}
|
||
});
|
||
|
||
xhr.addEventListener('load', function() {
|
||
showTransferProgress(false);
|
||
if (xhr.status !== 200) {
|
||
alert('下载失败: ' + (xhr.statusText || 'HTTP ' + xhr.status));
|
||
return;
|
||
}
|
||
const blob = new Blob([xhr.response]);
|
||
const blobUrl = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = blobUrl;
|
||
a.download = fileName;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(blobUrl);
|
||
updateStatus(fileName + ' 下载完成');
|
||
});
|
||
|
||
xhr.addEventListener('error', function() {
|
||
showTransferProgress(false);
|
||
alert('下载失败: 网络错误');
|
||
});
|
||
|
||
xhr.send();
|
||
}
|
||
|
||
// 下载选中的文件
|
||
function downloadFiles() {
|
||
const panelId = getSourcePanelId();
|
||
const selectedFiles = panelState[panelId].selectedFiles || [];
|
||
const sessionId = panelState[panelId].sessionId;
|
||
|
||
if (selectedFiles.length === 0) {
|
||
alert('请先选择要下载的文件');
|
||
return;
|
||
}
|
||
|
||
selectedFiles.forEach(function(item) {
|
||
if (!item || !item.path) return;
|
||
if (item.isDirectory) {
|
||
if (!confirm('当前选中包含文件夹,下载时将递归包含其所有子目录和文件,可能耗时较长,是否继续?')) {
|
||
return;
|
||
}
|
||
}
|
||
downloadFile(sessionId, item.path);
|
||
});
|
||
}
|
||
|
||
// 按方向执行跨面板传输(源面板 -> 目标面板)
|
||
function doTransfer(sourcePanelId, targetPanelId) {
|
||
const sourceSessionId = panelState[sourcePanelId].sessionId;
|
||
const targetSessionId = panelState[targetPanelId].sessionId;
|
||
const targetPath = panelState[targetPanelId].currentPath;
|
||
const selectedFiles = panelState[sourcePanelId].selectedFiles || [];
|
||
|
||
if (selectedFiles.length === 0) {
|
||
alert('请在' + (sourcePanelId === 'left' ? '左侧' : '右侧') + '面板选择要传输的文件');
|
||
return;
|
||
}
|
||
|
||
if (!targetPath) {
|
||
alert('目标面板路径无效,请先选择目标目录');
|
||
return;
|
||
}
|
||
|
||
let completed = 0;
|
||
let failed = 0;
|
||
const total = selectedFiles.length;
|
||
|
||
showTransferCountProgress(0, total, '');
|
||
updateTransferProgress(0, '传输中 (0/' + total + ')', true); // 后端无流式进度,使用动画条
|
||
|
||
const sourcePaths = selectedFiles
|
||
.filter(function(item) { return item && item.path; })
|
||
.map(function(item) { return item.path; });
|
||
|
||
const hasDirectory = selectedFiles.some(function(item) { return item && item.isDirectory; });
|
||
const recursive = hasDirectory; // 若包含目录,则自动递归
|
||
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/transfer',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sourceSessionId: sourceSessionId,
|
||
sourcePaths: sourcePaths,
|
||
targetSessionId: targetSessionId,
|
||
targetPath: targetPath,
|
||
recursive: recursive
|
||
}),
|
||
success: function(response) {
|
||
showTransferProgress(false);
|
||
if (response.success) {
|
||
updateStatus('传输成功');
|
||
} else {
|
||
alert('传输失败: ' + (response.message || '未知错误'));
|
||
updateStatus('传输失败');
|
||
}
|
||
loadFiles(targetPanelId);
|
||
},
|
||
error: function(xhr, status, error) {
|
||
showTransferProgress(false);
|
||
const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
|
||
alert('传输失败: ' + errMsg);
|
||
updateStatus('传输失败');
|
||
}
|
||
});
|
||
|
||
updateStatus('正在传输 ' + total + ' 个项目...');
|
||
}
|
||
|
||
// 传输到右侧:左侧面板选中的文件 -> 右侧面板
|
||
function transferToRight() {
|
||
doTransfer('left', 'right');
|
||
}
|
||
|
||
// 传输到左侧:右侧面板选中的文件 -> 左侧面板
|
||
function transferToLeft() {
|
||
doTransfer('right', 'left');
|
||
}
|
||
|
||
// ========== 文件删除功能(模块06)==========
|
||
|
||
// 工具栏删除:删除当前活动面板中选中的文件
|
||
function deleteFiles() {
|
||
deleteSelectedFiles(activePanelId);
|
||
}
|
||
|
||
// 删除指定面板中选中的文件(含确认)
|
||
function deleteSelectedFiles(panelId) {
|
||
const selectedFiles = panelState[panelId].selectedFiles;
|
||
|
||
if (selectedFiles.length === 0) {
|
||
alert('请先选择要删除的文件');
|
||
return;
|
||
}
|
||
|
||
const sessionId = panelState[panelId].sessionId;
|
||
let message;
|
||
if (selectedFiles.length === 1) {
|
||
const fileName = getFileNameFromPath(selectedFiles[0]);
|
||
message = '确定要删除 "' + fileName + '" 吗?此操作不可恢复。';
|
||
} else {
|
||
message = '确定要删除选中的 ' + selectedFiles.length + ' 个文件/文件夹吗?此操作不可恢复。';
|
||
}
|
||
|
||
if (confirm(message)) {
|
||
performDelete(sessionId, selectedFiles, panelId);
|
||
}
|
||
}
|
||
|
||
// 执行删除请求(单个走 DELETE,多个走批量 POST)
|
||
function performDelete(sessionId, paths, panelId) {
|
||
if (!panelId) panelId = activePanelId;
|
||
|
||
if (paths.length === 1) {
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/delete',
|
||
method: 'DELETE',
|
||
data: {
|
||
sessionId: sessionId,
|
||
path: paths[0]
|
||
},
|
||
success: function(response) {
|
||
if (response.success) {
|
||
updateStatus('删除成功');
|
||
loadFiles(panelId);
|
||
} else {
|
||
alert('删除失败: ' + (response.message || '未知错误'));
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
|
||
alert('删除失败: ' + errMsg);
|
||
}
|
||
});
|
||
} else {
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/batch-delete',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sessionId: sessionId,
|
||
paths: paths
|
||
}),
|
||
success: function(response) {
|
||
if (response.success) {
|
||
const result = response.data;
|
||
let msg = '成功删除 ' + result.successCount + ' 个';
|
||
if (result.failCount > 0) {
|
||
msg += ',失败 ' + result.failCount + ' 个。\n失败详情:\n' + (result.failedFiles || []).join('\n');
|
||
}
|
||
alert(msg);
|
||
updateStatus(msg);
|
||
loadFiles(panelId);
|
||
} else {
|
||
alert('批量删除失败: ' + (response.message || '未知错误'));
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
|
||
alert('批量删除失败: ' + errMsg);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 文件列表右键菜单
|
||
function showContextMenu(event, panelId, path, isDirectory) {
|
||
$('.context-menu').remove();
|
||
const menu = $('<div class="context-menu dropdown-menu show"></div>');
|
||
menu.css({
|
||
position: 'fixed',
|
||
left: event.clientX + 'px',
|
||
top: event.clientY + 'px',
|
||
zIndex: 1050
|
||
});
|
||
|
||
// 跨面板传输(根据当前面板添加对应方向的菜单项)
|
||
if (panelId === 'left') {
|
||
const transferRightItem = $('<a class="dropdown-item" href="#">传输到右侧</a>');
|
||
transferRightItem.on('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
menu.remove();
|
||
transferSingleBetweenPanels('left', 'right', path, isDirectory);
|
||
});
|
||
menu.append(transferRightItem);
|
||
} else if (panelId === 'right') {
|
||
const transferLeftItem = $('<a class="dropdown-item" href="#">传输到左侧</a>');
|
||
transferLeftItem.on('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
menu.remove();
|
||
transferSingleBetweenPanels('right', 'left', path, isDirectory);
|
||
});
|
||
menu.append(transferLeftItem);
|
||
}
|
||
|
||
menu.append('<div class="dropdown-divider"></div>');
|
||
const deleteItem = $('<a class="dropdown-item text-danger" href="#">删除</a>');
|
||
deleteItem.on('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
menu.remove();
|
||
deleteFileByPath(panelId, path);
|
||
});
|
||
menu.append(deleteItem);
|
||
|
||
const renameItem = $('<a class="dropdown-item" href="#">重命名</a>');
|
||
renameItem.on('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
menu.remove();
|
||
showRenameDialog(panelId, path);
|
||
});
|
||
menu.append(renameItem);
|
||
|
||
const mkdirItem = $('<a class="dropdown-item" href="#">新建文件夹</a>');
|
||
mkdirItem.on('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
menu.remove();
|
||
showMkdirDialog(panelId);
|
||
});
|
||
menu.append(mkdirItem);
|
||
|
||
$('body').append(menu);
|
||
$(document).one('click', function() {
|
||
menu.remove();
|
||
});
|
||
}
|
||
|
||
// 右键菜单:单个文件跨面板传输
|
||
function transferSingleBetweenPanels(sourcePanelId, targetPanelId, path, isDirectory) {
|
||
if (!path) {
|
||
return;
|
||
}
|
||
|
||
const sourceSessionId = panelState[sourcePanelId].sessionId;
|
||
const targetSessionId = panelState[targetPanelId].sessionId;
|
||
const targetPath = panelState[targetPanelId].currentPath;
|
||
|
||
if (!targetPath) {
|
||
alert('目标面板路径无效,请先选择目标目录');
|
||
return;
|
||
}
|
||
|
||
const fileName = getFileNameFromPath(path);
|
||
const isDir = isDirectory === true || isDirectory === 'true' || isDirectory === 'True';
|
||
const recursive = isDir; // 若为目录,则递归传输
|
||
|
||
showTransferCountProgress(0, 1, fileName);
|
||
updateTransferProgress(0, '传输中 ' + (fileName || ''), true); // 无流式进度,使用动画条
|
||
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/transfer',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sourceSessionId: sourceSessionId,
|
||
sourcePaths: [path],
|
||
targetSessionId: targetSessionId,
|
||
targetPath: targetPath,
|
||
recursive: recursive
|
||
}),
|
||
success: function(response) {
|
||
showTransferProgress(false);
|
||
if (response.success) {
|
||
updateStatus('传输成功');
|
||
} else {
|
||
alert('传输失败: ' + (response.message || '未知错误'));
|
||
updateStatus('传输失败');
|
||
}
|
||
loadFiles(targetPanelId);
|
||
},
|
||
error: function(xhr, status, error) {
|
||
showTransferProgress(false);
|
||
const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
|
||
alert('传输失败: ' + errMsg);
|
||
updateStatus('传输失败');
|
||
}
|
||
});
|
||
|
||
updateStatus('正在传输 "' + (fileName || path) + '" ...');
|
||
}
|
||
|
||
// 右键菜单:删除单个文件
|
||
function deleteFileByPath(panelId, path) {
|
||
const sessionId = panelState[panelId].sessionId;
|
||
const fileName = getFileNameFromPath(path);
|
||
if (confirm('确定要删除 "' + fileName + '" 吗?此操作不可恢复。')) {
|
||
performDelete(sessionId, [path], panelId);
|
||
}
|
||
}
|
||
|
||
// ========== 文件重命名功能(模块07)==========
|
||
|
||
// 验证文件名(前端)
|
||
function validateFileName(fileName) {
|
||
if (!fileName || fileName.trim() === '') {
|
||
alert('文件名不能为空');
|
||
return false;
|
||
}
|
||
const illegalChars = /[\\/:*?"<>|]/;
|
||
if (illegalChars.test(fileName)) {
|
||
alert('文件名不能包含以下字符: \\ / : * ? " < > |');
|
||
return false;
|
||
}
|
||
if (fileName.length > 255) {
|
||
alert('文件名过长(最大 255 个字符)');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// 获取选中项路径(仅当选中一个时返回,否则提示并返回 null)
|
||
function getSelectedPath(panelId) {
|
||
const selectedFiles = panelState[panelId].selectedFiles;
|
||
if (selectedFiles.length === 0) {
|
||
alert('请先选择一个文件或文件夹');
|
||
return null;
|
||
}
|
||
if (selectedFiles.length > 1) {
|
||
alert('一次只能重命名一个文件或文件夹');
|
||
return null;
|
||
}
|
||
return selectedFiles[0];
|
||
}
|
||
|
||
// 显示重命名对话框(无参时从当前活动面板取选中项;有参时用于右键菜单)
|
||
function showRenameDialog(panelId, path) {
|
||
if (path === undefined) {
|
||
panelId = getActivePanelId();
|
||
path = getSelectedPath(panelId);
|
||
if (!path) return;
|
||
}
|
||
|
||
const sessionId = panelState[panelId].sessionId;
|
||
const oldName = getFileNameFromPath(path);
|
||
|
||
const newName = prompt('请输入新名称:', oldName);
|
||
if (newName === null) return; // 用户取消
|
||
if (newName.trim() === '') {
|
||
alert('名称不能为空');
|
||
return;
|
||
}
|
||
if (newName.trim() === oldName) {
|
||
updateStatus('名称未更改');
|
||
return;
|
||
}
|
||
|
||
if (!validateFileName(newName.trim())) return;
|
||
|
||
doRename(sessionId, path, newName.trim(), panelId);
|
||
}
|
||
|
||
// 执行重命名请求
|
||
function doRename(sessionId, oldPath, newName, panelId) {
|
||
if (!panelId) panelId = getActivePanelId();
|
||
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/rename',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sessionId: sessionId,
|
||
oldPath: oldPath,
|
||
newName: newName
|
||
}),
|
||
success: function(response) {
|
||
if (response.success) {
|
||
updateStatus('重命名成功');
|
||
loadFiles(panelId);
|
||
} else {
|
||
alert('重命名失败: ' + (response.message || '未知错误'));
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
|
||
alert('重命名失败: ' + errMsg);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== 新建文件夹功能(模块08)==========
|
||
|
||
// 显示新建文件夹对话框(无参时对当前活动面板;有参时用于右键菜单等)
|
||
function showMkdirDialog(panelId) {
|
||
if (panelId === undefined) panelId = getActivePanelId();
|
||
const sessionId = panelState[panelId].sessionId;
|
||
const currentPath = panelState[panelId].currentPath;
|
||
|
||
if (!currentPath) {
|
||
alert('当前路径无效,请先选择目录');
|
||
return;
|
||
}
|
||
|
||
const folderName = prompt('请输入文件夹名称:', '新建文件夹');
|
||
if (folderName === null) return;
|
||
if (folderName.trim() === '') {
|
||
alert('文件夹名称不能为空');
|
||
return;
|
||
}
|
||
|
||
if (!validateDirectoryName(folderName.trim())) return;
|
||
|
||
createDirectory(sessionId, currentPath, folderName.trim(), panelId);
|
||
}
|
||
|
||
// 创建目录
|
||
function createDirectory(sessionId, parentPath, folderName, panelId) {
|
||
if (!validateDirectoryName(folderName)) return;
|
||
|
||
var fullPath = joinPath(parentPath, folderName, sessionId);
|
||
|
||
$.ajax({
|
||
url: API_BASE + 'api/files/mkdir',
|
||
method: 'POST',
|
||
contentType: 'application/json',
|
||
data: JSON.stringify({
|
||
sessionId: sessionId,
|
||
path: fullPath
|
||
}),
|
||
success: function(response) {
|
||
if (response.success) {
|
||
updateStatus('文件夹创建成功');
|
||
loadFiles(panelId || getActivePanelId());
|
||
} else {
|
||
alert('创建失败: ' + (response.message || '未知错误'));
|
||
}
|
||
},
|
||
error: function(xhr, status, error) {
|
||
var errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
|
||
alert('创建失败: ' + errMsg);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 拼接路径(父路径 + 子名称)
|
||
function joinPath(parentPath, childName, sessionId) {
|
||
childName = (childName || '').trim();
|
||
var isLocal = sessionId === 'local';
|
||
if (isLocal) {
|
||
if (parentPath.endsWith('\\') || parentPath.endsWith('/')) {
|
||
return parentPath + childName;
|
||
}
|
||
return parentPath + '\\' + childName;
|
||
}
|
||
if (parentPath.endsWith('/')) {
|
||
return parentPath + childName;
|
||
}
|
||
return parentPath + '/' + childName;
|
||
}
|
||
|
||
// 验证目录名(前端)
|
||
function validateDirectoryName(dirName) {
|
||
if (!dirName || dirName.trim() === '') {
|
||
alert('文件夹名称不能为空');
|
||
return false;
|
||
}
|
||
dirName = dirName.trim();
|
||
|
||
var illegalChars = /[\\/:*?"<>|]/;
|
||
if (illegalChars.test(dirName)) {
|
||
alert('文件夹名称不能包含以下字符: \\ / : * ? " < > |');
|
||
return false;
|
||
}
|
||
if (dirName.startsWith('.') || dirName.endsWith('.')) {
|
||
alert('文件夹名称不能以点开头或结尾');
|
||
return false;
|
||
}
|
||
if (dirName.length > 255) {
|
||
alert('文件夹名称过长(最大 255 个字符)');
|
||
return false;
|
||
}
|
||
var upperName = dirName.toUpperCase();
|
||
var reservedNames = ['CON', 'PRN', 'AUX', 'NUL',
|
||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
|
||
if (reservedNames.indexOf(upperName) !== -1) {
|
||
alert('文件夹名称不能使用系统保留名称');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|