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

84
Makefile Normal file
View File

@@ -0,0 +1,84 @@
SHELL := /bin/bash
APP_NAME := sftp-manager
PORT := 48081
BASE_PATH := /sftp-manager
APP_URL := http://localhost:$(PORT)$(BASE_PATH)
MVN := mvn
DOCKER_COMPOSE := docker compose
.DEFAULT_GOAL := help
.PHONY: help check-mvn check-docker install build test start stop restart status logs deploy bootstrap clean jar-start jar-stop jar-restart
help:
@echo "Available targets:"
@echo " make bootstrap - One command install + deploy + start (Docker)"
@echo " make deploy - Build app and start with Docker Compose"
@echo " make install - Maven install (skip tests)"
@echo " make build - Maven package (skip tests)"
@echo " make test - Run all tests"
@echo " make start - Start service by Docker Compose"
@echo " make stop - Stop service by Docker Compose"
@echo " make restart - Restart Docker service"
@echo " make status - Show Docker service status"
@echo " make logs - Tail service logs"
@echo " make jar-start - Start packaged jar using deploy.sh"
@echo " make jar-stop - Stop packaged jar using deploy.sh"
@echo " make jar-restart - Restart packaged jar using deploy.sh"
check-mvn:
@command -v $(MVN) >/dev/null 2>&1 || { echo "Error: mvn not found"; exit 1; }
check-docker:
@command -v docker >/dev/null 2>&1 || { echo "Error: docker not found"; exit 1; }
@$(DOCKER_COMPOSE) version >/dev/null 2>&1 || { echo "Error: docker compose not available"; exit 1; }
install: check-mvn
@echo ">>> Installing project artifacts (skip tests)..."
@$(MVN) clean install -DskipTests
build: check-mvn
@echo ">>> Packaging application (skip tests)..."
@$(MVN) clean package -DskipTests
test: check-mvn
@echo ">>> Running tests..."
@$(MVN) clean test
start: check-docker
@echo ">>> Starting $(APP_NAME) with Docker Compose..."
@$(DOCKER_COMPOSE) up -d --build
@echo ">>> Started. Visit: $(APP_URL)"
stop: check-docker
@echo ">>> Stopping $(APP_NAME)..."
@$(DOCKER_COMPOSE) down
restart: stop start
status: check-docker
@$(DOCKER_COMPOSE) ps
logs: check-docker
@$(DOCKER_COMPOSE) logs -f --tail=200 $(APP_NAME)
deploy: build start
bootstrap: install start
clean: check-mvn
@$(MVN) clean
jar-start: build
@chmod +x ./deploy.sh
@./deploy.sh start
jar-stop:
@chmod +x ./deploy.sh
@./deploy.sh stop
jar-restart: build
@chmod +x ./deploy.sh
@./deploy.sh restart

View File

@@ -8,8 +8,8 @@ services:
- "48081:48081"
volumes:
# 默认映射到用户家目录Linux/Mac 为 $HOME未设置时为当前目录
- ${HOME:-.}/sftp-manager/data:/app/data
- ${HOME:-.}/sftp-manager/logs:/app/logs
- ./docker/data:/app/data
- ./docker/logs:/app/logs
environment:
- SPRING_PROFILES_ACTIVE=prod
restart: unless-stopped

File diff suppressed because it is too large Load Diff

View File

@@ -4,63 +4,72 @@
<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 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>
</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>
<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 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>
<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;">
<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; margin-left: 10px; width: 220px;">
<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>
</div>
</div>
</section>
<!-- 双面板区域 -->
<div class="panels-container">
<!-- 左面板 -->
<div class="panel" id="left-panel">
<div class="panel-header">
<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>
@@ -69,22 +78,29 @@
<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" onclick="goUp('left')"></button>
<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>
<div class="file-list" id="left-file-list">
<div class="text-center text-muted p-3">加载中...</div>
</div>
</div>
</article>
<!-- 右面板 -->
<div class="panel" id="right-panel">
<div class="panel-header">
<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>
@@ -93,27 +109,48 @@
<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" onclick="goUp('right')"></button>
<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>
</div>
</div>
</article>
</section>
</main>
<!-- 状态栏 -->
<div class="status-bar bg-light border-top">
<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">

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==========
// 工具栏删除:删除当前活动面板中选中的文件