Rebuild UI for master-slave file distribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,116 +4,153 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SFTP文件管理器</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<nav class="navbar navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1">SFTP文件管理器</span>
|
||||
<div>
|
||||
<button class="btn btn-primary btn-sm" onclick="showConnectionDialog()">连接管理</button>
|
||||
</div>
|
||||
<div class="app-shell">
|
||||
<header class="topbar" role="banner">
|
||||
<div class="topbar-left">
|
||||
<h1 class="brand-title">SFTP Manager</h1>
|
||||
<p class="brand-subtitle">主从文件分发工作台</p>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 页面头部信息 -->
|
||||
<header class="app-header">
|
||||
<div class="app-header-main">
|
||||
<h1 class="app-title">文件工作台</h1>
|
||||
<p class="app-subtitle">双面板本地 / SFTP 文件管理器,支持拖拽、键盘快捷键与批量操作。</p>
|
||||
</div>
|
||||
<div class="app-header-tags">
|
||||
<span class="header-tag">双面板布局</span>
|
||||
<span class="header-tag">SFTP / 本地</span>
|
||||
<span class="header-tag">拖拽上传</span>
|
||||
<div class="topbar-actions">
|
||||
<button type="button" class="btn btn-light btn-sm" onclick="showConnectionDialog()">连接管理</button>
|
||||
<button type="button" class="btn btn-outline-light btn-sm" onclick="refreshPanels()">刷新全部</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar bg-light border-bottom">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="uploadFiles()">上传</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="downloadFiles()">下载</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="transferToRight()">传输到右侧</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="transferToLeft()">传输到左侧</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFiles()">删除</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="showRenameDialog()">重命名</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="showMkdirDialog()">新建文件夹</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-show-hidden" onclick="toggleShowHidden()" title="切换是否显示隐藏文件">显示隐藏文件</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshPanels()">刷新</button>
|
||||
</div>
|
||||
<!-- 文件上传输入框(隐藏) -->
|
||||
<input type="file" id="file-input" multiple style="display:none">
|
||||
<!-- 传输进度(上传/下载/跨面板传输) -->
|
||||
<div class="transfer-progress" id="transfer-progress" style="display:none;">
|
||||
<span class="transfer-progress-label" id="transfer-progress-label"></span>
|
||||
<div class="progress transfer-progress-bar" style="height: 20px; margin-left: 10px; width: 220px;">
|
||||
<div class="progress-bar" id="transfer-progress-bar" role="progressbar" style="width: 0%">0%</div>
|
||||
<main class="workspace" role="main">
|
||||
<section class="summary-strip" aria-label="传输策略说明">
|
||||
<div class="summary-item">
|
||||
<span class="summary-k">主端</span>
|
||||
<span class="summary-v" id="master-summary">左侧:本机 / 单台远端</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-k">从端</span>
|
||||
<span class="summary-v" id="slave-summary">右侧:单台或多台远端</span>
|
||||
</div>
|
||||
<div class="summary-item summary-item-wide">
|
||||
<span class="summary-k">策略</span>
|
||||
<span class="summary-v">主向从发送(支持单发/多发),右侧可同时选择多目标进行分发</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 双面板区域 -->
|
||||
<div class="panels-container">
|
||||
<!-- 左面板 -->
|
||||
<div class="panel" id="left-panel">
|
||||
<div class="panel-header">
|
||||
<select class="form-select form-select-sm panel-mode" id="left-mode" onchange="onModeChange('left')">
|
||||
<option value="local">本地文件</option>
|
||||
<option value="sftp">SFTP服务器</option>
|
||||
</select>
|
||||
<div class="connection-status" id="left-status" style="display:none;">
|
||||
<span class="status-dot" data-status="disconnected"></span>
|
||||
<span class="status-text">未连接</span>
|
||||
<section class="ops-toolbar" aria-label="操作区">
|
||||
<div class="ops-group">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="uploadFiles()">上传到主端</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="downloadFiles()">从主端下载</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" onclick="deleteFiles()">删除</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="showRenameDialog()">重命名</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="showMkdirDialog()">新建文件夹</button>
|
||||
</div>
|
||||
<div class="ops-group ops-group-right">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="btn-transfer-master" onclick="transferMasterToSlaves()">主 -> 从发送</button>
|
||||
<button type="button" class="btn btn-outline-warning btn-sm" id="btn-retry-failed" onclick="retryFailedDispatch()" style="display:none;">重试失败目标</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn-show-hidden" onclick="toggleShowHidden()" title="切换隐藏文件">显示隐藏文件</button>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple style="display:none">
|
||||
<div class="transfer-progress" id="transfer-progress" style="display:none;" aria-live="polite">
|
||||
<span class="transfer-progress-label" id="transfer-progress-label"></span>
|
||||
<div class="progress transfer-progress-bar" style="height: 20px; width: 260px;">
|
||||
<div class="progress-bar" id="transfer-progress-bar" role="progressbar" style="width: 0%">0%</div>
|
||||
</div>
|
||||
<select class="form-select form-select-sm connection-select" id="left-connection" style="display:none;" onchange="onConnectionChange('left')">
|
||||
<option value="">选择连接</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="path-bar">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="goUp('left')">↑</button>
|
||||
<input type="text" class="form-control form-control-sm path-input" id="left-path" readonly>
|
||||
</div>
|
||||
<div class="file-list" id="left-file-list">
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右面板 -->
|
||||
<div class="panel" id="right-panel">
|
||||
<div class="panel-header">
|
||||
<select class="form-select form-select-sm panel-mode" id="right-mode" onchange="onModeChange('right')">
|
||||
<option value="local">本地文件</option>
|
||||
<option value="sftp">SFTP服务器</option>
|
||||
</select>
|
||||
<div class="connection-status" id="right-status" style="display:none;">
|
||||
<span class="status-dot" data-status="disconnected"></span>
|
||||
<span class="status-text">未连接</span>
|
||||
<section class="panes-grid" aria-label="主从面板">
|
||||
<article class="pane panel pane-master" id="left-panel" aria-label="主端面板">
|
||||
<header class="pane-head">
|
||||
<div class="pane-head-title">
|
||||
<span class="pane-badge pane-badge-master">主端</span>
|
||||
<span class="pane-head-text">发送源</span>
|
||||
</div>
|
||||
<div class="pane-head-controls">
|
||||
<label class="visually-hidden" for="left-mode">主端模式</label>
|
||||
<select class="form-select form-select-sm panel-mode" id="left-mode" onchange="onModeChange('left')">
|
||||
<option value="local">本地文件</option>
|
||||
<option value="sftp">SFTP服务器</option>
|
||||
</select>
|
||||
<div class="connection-status" id="left-status" style="display:none;">
|
||||
<span class="status-dot" data-status="disconnected"></span>
|
||||
<span class="status-text">未连接</span>
|
||||
</div>
|
||||
<label class="visually-hidden" for="left-connection">主端连接</label>
|
||||
<select class="form-select form-select-sm connection-select" id="left-connection" style="display:none;" onchange="onConnectionChange('left')">
|
||||
<option value="">选择连接</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
<div class="path-bar">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="goUp('left')" aria-label="主端返回上级">↑</button>
|
||||
<input type="text" class="form-control form-control-sm path-input" id="left-path" readonly>
|
||||
</div>
|
||||
<select class="form-select form-select-sm connection-select" id="right-connection" style="display:none;" onchange="onConnectionChange('right')">
|
||||
<option value="">选择连接</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="path-bar">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="goUp('right')">↑</button>
|
||||
<input type="text" class="form-control form-control-sm path-input" id="right-path" readonly>
|
||||
</div>
|
||||
<div class="file-list" id="right-file-list">
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-list" id="left-file-list">
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<div class="status-bar bg-light border-top">
|
||||
<article class="pane panel pane-slave" id="right-panel" aria-label="从端面板">
|
||||
<header class="pane-head">
|
||||
<div class="pane-head-title">
|
||||
<span class="pane-badge pane-badge-slave">从端</span>
|
||||
<span class="pane-head-text">接收目标(单台或多台)</span>
|
||||
</div>
|
||||
<div class="pane-head-controls">
|
||||
<label class="visually-hidden" for="right-mode">从端模式</label>
|
||||
<select class="form-select form-select-sm panel-mode" id="right-mode" onchange="onModeChange('right')">
|
||||
<option value="local">本地文件</option>
|
||||
<option value="sftp">SFTP服务器</option>
|
||||
</select>
|
||||
<div class="connection-status" id="right-status" style="display:none;">
|
||||
<span class="status-dot" data-status="disconnected"></span>
|
||||
<span class="status-text">未连接</span>
|
||||
</div>
|
||||
<label class="visually-hidden" for="right-connection">从端连接</label>
|
||||
<select class="form-select form-select-sm connection-select" id="right-connection" style="display:none;" onchange="onConnectionChange('right')">
|
||||
<option value="">选择连接</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="slave-targets" aria-label="从端目标选择">
|
||||
<div class="slave-targets-head">
|
||||
<span>分发目标</span>
|
||||
<div class="slave-targets-actions">
|
||||
<button type="button" class="btn btn-outline-secondary btn-xs" onclick="selectAllSlaveTargets(true)">全选</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-xs" onclick="selectAllSlaveTargets(false)">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
id="slave-target-search"
|
||||
class="form-control form-control-sm slave-target-search"
|
||||
type="text"
|
||||
placeholder="搜索从端目标(名称 / 主机 / 用户)"
|
||||
oninput="filterSlaveTargets(this.value)">
|
||||
<div id="slave-target-list" class="slave-target-list">
|
||||
<div class="text-muted">暂无可用目标,请先在连接管理中连接服务器。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="path-bar">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="goUp('right')" aria-label="从端返回上级">↑</button>
|
||||
<input type="text" class="form-control form-control-sm path-input" id="right-path" readonly>
|
||||
</div>
|
||||
<div class="file-list" id="right-file-list">
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="status-bar">
|
||||
<span id="status-text" aria-live="polite">就绪</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- 连接管理对话框 -->
|
||||
<div class="modal fade" id="connectionModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
@@ -125,9 +162,7 @@
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-primary" onclick="showAddConnectionDialog()">添加连接</button>
|
||||
</div>
|
||||
<div class="connection-list" id="connection-list">
|
||||
<!-- 连接列表动态生成 -->
|
||||
</div>
|
||||
<div class="connection-list" id="connection-list"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
@@ -136,7 +171,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加连接对话框 -->
|
||||
<div class="modal fade" id="addConnectionModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -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)==========
|
||||
|
||||
// 工具栏删除:删除当前活动面板中选中的文件
|
||||
|
||||
Reference in New Issue
Block a user