Rebuild UI for master-slave file distribution

This commit is contained in:
liumangmang
2026-03-10 18:08:07 +08:00
parent 0c443b029d
commit cbb0d42a46
5 changed files with 1164 additions and 626 deletions

View File

@@ -28,6 +28,17 @@ let activePanelId = 'left';
// 是否展示隐藏文件(默认不展示)
let showHiddenFiles = false;
// 从端目标会话(支持多选)
let selectedSlaveSessions = new Set(['local']);
// 从端目标最近一次分发状态
let slaveDeliveryStatus = {};
// 最近一次分发上下文(用于失败重试)
let lastDispatchContext = null;
let lastFailedSlaveSessions = [];
let dispatchRunning = false;
// 初始化
$(document).ready(function() {
initApp();
@@ -42,6 +53,10 @@ function initApp() {
// 加载活跃连接
loadActiveConnections();
// 初始化从端多目标
renderSlaveTargetList();
refreshTopologySummary();
// 绑定键盘事件
bindKeyboardEvents();
@@ -54,6 +69,22 @@ function initApp() {
// 面板点击切换活动面板
$('#left-panel').on('click', function() { setActivePanel('left'); });
$('#right-panel').on('click', function() { setActivePanel('right'); });
// 从端多目标勾选
$(document).on('change', '.slave-target-check', function() {
const sessionId = $(this).data('session-id');
if (!sessionId) return;
if (this.checked) {
selectedSlaveSessions.add(sessionId);
} else {
selectedSlaveSessions.delete(sessionId);
}
if (selectedSlaveSessions.size === 0) {
selectedSlaveSessions.add(panelState.right.sessionId || 'local');
renderSlaveTargetList();
}
refreshTopologySummary();
});
}
// 设置当前活动面板并应用高亮样式
@@ -61,6 +92,195 @@ function setActivePanel(panelId) {
activePanelId = panelId;
$('#left-panel, #right-panel').removeClass('active-panel');
$('#' + panelId + '-panel').addClass('active-panel');
refreshTopologySummary();
}
function getSessionDisplayName(sessionId) {
if (!sessionId || sessionId === 'local') {
return '本机(local)';
}
const conn = activeConnections[sessionId];
if (!conn) {
return '会话 ' + sessionId.substring(0, 8);
}
const base = conn.name || conn.host || '远端';
return base + ':' + (conn.port || 22);
}
function formatStatusTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return hh + ':' + mm + ':' + ss;
}
function markDeliveryStatus(sessionId, state, message) {
if (!sessionId) return;
slaveDeliveryStatus[sessionId] = {
state: state,
message: message || '',
time: Date.now()
};
}
function getDeliveryStatusText(meta) {
if (!meta) return '未分发';
const timeText = formatStatusTime(meta.time);
if (meta.state === 'success') {
return '成功 ' + timeText;
}
if (meta.state === 'failed') {
return '失败 ' + timeText;
}
if (meta.state === 'running') {
return '发送中...';
}
return '未分发';
}
function updateRetryFailedButton() {
const retryBtn = $('#btn-retry-failed');
if (retryBtn.length === 0) return;
if (lastFailedSlaveSessions.length > 0 && !dispatchRunning) {
retryBtn.show();
retryBtn.text('重试失败目标(' + lastFailedSlaveSessions.length + ')');
retryBtn.prop('disabled', false);
} else {
retryBtn.hide();
}
}
function getSlaveTargetSessions() {
const targets = [];
selectedSlaveSessions.forEach(function(sessionId) {
if (sessionId) {
targets.push(sessionId);
}
});
if (targets.length === 0) {
const fallback = panelState.right.mode === 'sftp'
? (panelState.right.sessionId && panelState.right.sessionId !== 'local' ? panelState.right.sessionId : null)
: 'local';
if (fallback) {
targets.push(fallback);
}
}
return targets;
}
function renderSlaveTargetList() {
const box = $('#slave-target-list');
if (box.length === 0) return;
const sessions = [];
if (panelState.right.mode === 'local') {
sessions.push({
sessionId: 'local',
label: '本机(local)',
info: '127.0.0.1'
});
} else {
Object.keys(activeConnections || {}).forEach(function(sessionId) {
const conn = activeConnections[sessionId];
if (!conn) return;
sessions.push({
sessionId: sessionId,
label: conn.name || conn.host || ('会话 ' + sessionId.substring(0, 8)),
info: (conn.username || 'user') + '@' + (conn.host || 'host') + ':' + (conn.port || 22)
});
});
}
const validSet = new Set(sessions.map(function(item) { return item.sessionId; }));
const normalizedSelected = new Set();
selectedSlaveSessions.forEach(function(sessionId) {
if (validSet.has(sessionId)) {
normalizedSelected.add(sessionId);
}
});
if (normalizedSelected.size === 0 && sessions.length > 0) {
normalizedSelected.add(sessions[0].sessionId);
}
selectedSlaveSessions = normalizedSelected;
if (sessions.length === 0) {
box.html('<div class="text-muted">当前为从端SFTP模式但暂无可用连接请先在连接管理中连接服务器。</div>');
refreshTopologySummary();
return;
}
let html = '';
sessions.forEach(function(item) {
const checked = selectedSlaveSessions.has(item.sessionId) ? 'checked' : '';
const searchable = (item.label + ' ' + item.info).toLowerCase();
const statusMeta = slaveDeliveryStatus[item.sessionId];
const statusClass = statusMeta ? (' slave-target-status-' + statusMeta.state) : '';
const statusTitle = statusMeta && statusMeta.message ? $('<div>').text(statusMeta.message).html() : '';
const statusText = getDeliveryStatusText(statusMeta);
html += '<label class="slave-target-item" data-search-key="' + $('<div>').text(searchable).html() + '" title="' + $('<div>').text(item.info).html() + '">' +
'<input class="form-check-input slave-target-check" type="checkbox" data-session-id="' + item.sessionId + '" ' + checked + '>' +
'<span class="slave-target-main">' + $('<div>').text(item.label).html() + '</span>' +
'<span class="slave-target-sub">' + $('<div>').text(item.info).html() + '</span>' +
'<span class="slave-target-status' + statusClass + '" title="' + statusTitle + '">' + $('<div>').text(statusText).html() + '</span>' +
'</label>';
});
box.html(html || '<div class="text-muted">暂无目标。</div>');
filterSlaveTargets($('#slave-target-search').val() || '');
refreshTopologySummary();
updateRetryFailedButton();
}
function filterSlaveTargets(keyword) {
const value = (keyword || '').trim().toLowerCase();
const list = $('#slave-target-list .slave-target-item');
if (list.length === 0) return;
list.each(function() {
const key = ($(this).attr('data-search-key') || '').toLowerCase();
const hit = value === '' || key.indexOf(value) !== -1;
$(this).toggleClass('is-hidden', !hit);
});
}
function refreshTopologySummary() {
const masterEl = $('#master-summary');
const slaveEl = $('#slave-summary');
if (masterEl.length === 0 || slaveEl.length === 0) return;
masterEl.text('左侧主端:' + getSessionDisplayName(panelState.left.sessionId));
const slaves = getSlaveTargetSessions().map(getSessionDisplayName);
if (slaves.length === 0) {
slaveEl.text('右侧从端目标(0):请先连接从端目标');
} else {
slaveEl.text('右侧从端目标(' + slaves.length + ')' + slaves.join(' / '));
}
}
function selectAllSlaveTargets(checked) {
const mode = panelState.right.mode;
if (checked) {
selectedSlaveSessions.clear();
if (mode === 'local') {
selectedSlaveSessions.add('local');
} else {
Object.keys(activeConnections || {}).forEach(function(sessionId) {
selectedSlaveSessions.add(sessionId);
});
}
} else {
selectedSlaveSessions.clear();
if (mode === 'local') {
selectedSlaveSessions.add('local');
} else if (panelState.right.sessionId && panelState.right.sessionId !== 'local') {
selectedSlaveSessions.add(panelState.right.sessionId);
}
}
renderSlaveTargetList();
}
// 初始化面板路径
@@ -418,6 +638,9 @@ function handleFileDrop(data, targetPanelId) {
// 更新路径输入框
function updatePathInput(panelId, path) {
$(`#${panelId}-path`).val(path || '');
if (panelId === 'right') {
refreshTopologySummary();
}
}
// 更新状态栏
@@ -456,6 +679,17 @@ function onModeChange(panelId) {
updateStatus('请选择SFTP连接');
}
}
if (panelId === 'right') {
selectedSlaveSessions.clear();
if (panelState.right.mode === 'local') {
selectedSlaveSessions.add('local');
} else if (panelState.right.sessionId && panelState.right.sessionId !== 'local') {
selectedSlaveSessions.add(panelState.right.sessionId);
}
renderSlaveTargetList();
}
refreshTopologySummary();
}
// 连接切换
@@ -469,6 +703,17 @@ function onConnectionChange(panelId) {
$(`#${panelId}-file-list`).html('<div class="text-center text-muted p-3">请选择SFTP连接</div>');
showConnectionStatus(panelId, null);
}
if (panelId === 'right') {
selectedSlaveSessions.clear();
if (panelState.right.mode === 'local') {
selectedSlaveSessions.add('local');
} else if (panelState.right.sessionId && panelState.right.sessionId !== 'local') {
selectedSlaveSessions.add(panelState.right.sessionId);
}
renderSlaveTargetList();
}
refreshTopologySummary();
}
// 更新连接状态显示状态connected / disconnected / connecting
@@ -514,6 +759,7 @@ function loadActiveConnections() {
activeConnections = response.data;
updateConnectionSelect('left');
updateConnectionSelect('right');
renderSlaveTargetList();
['left', 'right'].forEach(function(panelId) {
if (panelState[panelId].mode === 'sftp') {
showConnectionStatus(panelId, panelState[panelId].sessionId);
@@ -570,6 +816,7 @@ function refreshPanels() {
activeConnections = response.data;
updateConnectionSelect('left');
updateConnectionSelect('right');
renderSlaveTargetList();
['left', 'right'].forEach(function(panelId) {
if (panelState[panelId].mode === 'sftp') {
showConnectionStatus(panelId, panelState[panelId].sessionId);
@@ -737,6 +984,7 @@ function deleteConnection(id) {
loadActiveConnections();
updateConnectionSelect('left');
updateConnectionSelect('right');
renderSlaveTargetList();
} else {
alert('删除失败: ' + (response.message || '未知错误'));
}
@@ -761,6 +1009,8 @@ function disconnectFromSftp(sessionId) {
updatePanelsAfterDisconnect(sessionId);
updateConnectionSelect('left');
updateConnectionSelect('right');
selectedSlaveSessions.delete(sessionId);
renderSlaveTargetList();
['left', 'right'].forEach(function(panelId) {
showConnectionStatus(panelId, panelState[panelId].sessionId);
});
@@ -789,6 +1039,8 @@ function updatePanelsAfterDisconnect(sessionId) {
initPanelPath(panelId);
}
});
renderSlaveTargetList();
refreshTopologySummary();
}
// 连接成功后只更新当前活动面板为新连接,另一侧保持原选择,便于左右选不同 SFTP
@@ -870,6 +1122,10 @@ function connectToServer(connId) {
activeConnections[sessionId] = conn;
updateConnectionSelect('left');
updateConnectionSelect('right');
if (panelState.right.mode === 'sftp' && panelState.right.sessionId === sessionId) {
selectedSlaveSessions.add(sessionId);
}
renderSlaveTargetList();
updatePanelStateWithConnection(sessionId, conn);
loadConnectionList();
if (typeof bootstrap !== 'undefined') {
@@ -1198,6 +1454,213 @@ function transferToLeft() {
doTransfer('right', 'left');
}
// 主端 -> 多从端分发
function transferMasterToSlaves() {
const transferBtn = $('#btn-transfer-master');
const sourceSessionId = panelState.left.sessionId;
const sourceSelected = panelState.left.selectedFiles || [];
if (sourceSelected.length === 0) {
alert('请先在左侧主端选择要发送的文件');
return;
}
const sourcePaths = sourceSelected
.filter(function(item) { return item && item.path; })
.map(function(item) { return item.path; });
if (sourcePaths.length === 0) {
alert('未找到有效源路径');
return;
}
const targetPath = panelState.right.currentPath;
if (!targetPath) {
alert('请先在右侧选择目标目录路径');
return;
}
const targetSessions = getSlaveTargetSessions();
if (targetSessions.length === 0) {
alert('请至少选择一个从端目标');
return;
}
if (sourceSessionId === 'local' && targetSessions.length === 1 && targetSessions[0] === 'local') {
alert('主端与从端均为本机,无需执行分发');
return;
}
const hasDirectory = sourceSelected.some(function(item) { return item && item.isDirectory; });
const recursive = hasDirectory;
runDispatchToTargets({
sourceSessionId: sourceSessionId,
sourcePaths: sourcePaths,
targetPath: targetPath,
recursive: recursive,
targetSessions: targetSessions,
transferBtn: transferBtn,
saveAsLastContext: true
});
}
function runDispatchToTargets(options) {
const transferBtn = options.transferBtn || $('#btn-transfer-master');
const sourceSessionId = options.sourceSessionId;
const sourcePaths = options.sourcePaths || [];
const targetPath = options.targetPath;
const recursive = !!options.recursive;
const targetSessions = options.targetSessions || [];
const saveAsLastContext = options.saveAsLastContext === true;
if (dispatchRunning) {
alert('已有分发任务在执行,请稍后再试');
return;
}
if (targetSessions.length === 0) {
alert('没有可分发的目标');
return;
}
dispatchRunning = true;
updateRetryFailedButton();
let index = 0;
let success = 0;
let fail = 0;
let failMessages = [];
const failedSessions = [];
const MAX_CONCURRENCY = 2;
let running = 0;
let finished = 0;
showTransferProgress(true, '开始主从分发...');
updateTransferProgress(0, '分发 0/' + targetSessions.length, true);
transferBtn.prop('disabled', true).text('分发中...');
if (saveAsLastContext) {
lastDispatchContext = {
sourceSessionId: sourceSessionId,
sourcePaths: sourcePaths.slice(),
targetPath: targetPath,
recursive: recursive
};
lastFailedSlaveSessions = [];
}
function done() {
dispatchRunning = false;
showTransferProgress(false);
transferBtn.prop('disabled', false).text('主 -> 从发送');
const msg = '分发完成:成功 ' + success + ' 台,失败 ' + fail + ' 台';
updateStatus(msg);
lastFailedSlaveSessions = failedSessions.slice();
updateRetryFailedButton();
renderSlaveTargetList();
if (fail > 0) {
alert(msg + '\n\n失败详情:\n' + failMessages.join('\n'));
}
loadFiles('right');
}
function schedule() {
if (finished >= targetSessions.length) {
if (running === 0) {
done();
}
return;
}
while (running < MAX_CONCURRENCY && index < targetSessions.length) {
const targetSessionId = targetSessions[index++];
running++;
markDeliveryStatus(targetSessionId, 'running', '分发中');
renderSlaveTargetList();
const label = '分发中 (' + (finished + 1) + '/' + targetSessions.length + ') -> ' + getSessionDisplayName(targetSessionId);
updateTransferProgress(0, label, true);
$.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) {
if (response.success) {
success++;
markDeliveryStatus(targetSessionId, 'success', '分发成功');
} else {
fail++;
failedSessions.push(targetSessionId);
const msg = response.message || '未知错误';
failMessages.push(getSessionDisplayName(targetSessionId) + ' - ' + msg);
markDeliveryStatus(targetSessionId, 'failed', msg);
}
},
error: function(xhr, status, error) {
const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error;
fail++;
failedSessions.push(targetSessionId);
failMessages.push(getSessionDisplayName(targetSessionId) + ' - ' + errMsg);
markDeliveryStatus(targetSessionId, 'failed', errMsg);
},
complete: function() {
running--;
finished++;
renderSlaveTargetList();
schedule();
}
});
}
}
schedule();
}
function retryFailedDispatch() {
if (dispatchRunning) {
alert('当前有任务进行中,请稍后重试');
return;
}
if (!lastDispatchContext || lastFailedSlaveSessions.length === 0) {
alert('当前没有可重试的失败目标');
return;
}
const targets = lastFailedSlaveSessions.filter(function(sessionId) {
if (sessionId === 'local') {
return panelState.right.mode === 'local';
}
return !!activeConnections[sessionId];
});
if (targets.length === 0) {
alert('失败目标已失效(可能已断开),请重新选择目标后分发');
lastFailedSlaveSessions = [];
updateRetryFailedButton();
return;
}
runDispatchToTargets({
sourceSessionId: lastDispatchContext.sourceSessionId,
sourcePaths: lastDispatchContext.sourcePaths,
targetPath: lastDispatchContext.targetPath,
recursive: lastDispatchContext.recursive,
targetSessions: targets,
transferBtn: $('#btn-transfer-master'),
saveAsLastContext: false
});
}
// ========== 文件删除功能模块06==========
// 工具栏删除:删除当前活动面板中选中的文件