feat: 支持文件对传功能

This commit is contained in:
chaoszhu
2025-08-09 20:21:04 +08:00
parent 3ea1d8101d
commit e29a0acd3d
21 changed files with 2955 additions and 39 deletions

50
LICENSE
View File

@@ -1,21 +1,37 @@
MIT License
Business Source License 1.1
Copyright (c) 2017 Sunil Wang
Licensor: Chaos Zhu
Licensed Work: EasyNode (WebSSH & WebSFTP Panel)
The Licensed Work is © 2025 Chaos Zhu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
License Grant:
Under this license, you are granted the right to use, copy, modify, and distribute the Licensed Work, subject to the Usage Limitations below, until the Change Date.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Usage Limitations:
Free use is permitted only for:
- Personal, non-commercial purposes
- Educational and research purposes
- Internal evaluation or testing
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Commercial use, including but not limited to:
- Providing the Licensed Work as a hosted service (SaaS)
- Selling, licensing, or charging for access
requires a separate commercial agreement with the Licensor.
Prohibited Uses:
Without a commercial license, you may not:
- Use the Licensed Work for any commercial purpose
- Remove or alter copyright, license notices, or trademarks
- Rebrand or redistribute as your own product
Change Date:
1 January 2035
Change License:
On the Change Date, the Licensed Work will be made available under the Apache License 2.0.
Disclaimer:
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
Limitation of Liability:
IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM THE USE OF THE LICENSED WORK.

View File

@@ -22,6 +22,7 @@ module.exports = {
chatHistoryDBPath: path.join(process.cwd(),'app/db/chat-history.db'),
favoriteSftpDBPath: path.join(process.cwd(),'app/db/favorite-sftp.db'),
proxyDBPath: path.join(process.cwd(),'app/db/proxy.db'),
fileTransferDBPath: path.join(process.cwd(),'app/db/file-transfer.db'),
apiPrefix: '/api/v1',
logConfig: {
outDir: path.join(process.cwd(),'./app/db/logs'),

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@ const wsSftpV2 = require('./socket/sftp-v2')
const wsDocker = require('./socket/docker')
const wsOnekey = require('./socket/onekey')
const wsServerStatus = require('./socket/server-status')
const wsFileTransfer = require('./socket/file-transfer')
const { throwError } = require('./utils/tools')
const httpServer = () => {
@@ -28,6 +29,7 @@ function serverHandler(app, server) {
wsDocker(server) // docker
wsOnekey(server) // 一键指令
wsServerStatus(server) // 服务器状态监控
wsFileTransfer(server) // 文件传输
app.context.throwError = throwError // 常用方法挂载全局ctx上
app.use(compose(middlewares))
// 捕获error.js模块抛出的服务错误

View File

@@ -0,0 +1,989 @@
const path = require('path')
const { Server } = require('socket.io')
const { Client: SSHClient } = require('ssh2')
const consola = require('consola')
const { verifyAuthSync } = require('../utils/verify-auth')
const { isAllowedIp, fileTransferThrottle } = require('../utils/tools')
const { getConnectionOptions } = require('./terminal')
const { FileTransferDB, HostListDB } = require('../utils/db-class')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
const fileTransferDB = new FileTransferDB().getInstance()
const hostListDB = new HostListDB().getInstance()
// 全局传输任务管理
const activeTasks = new Map() // taskId -> { process, sshClient, status, ... }
// 任务排序函数运行中的任务优先然后按updateTime降序
function sortTasks(tasks) {
return tasks.sort((a, b) => {
// 状态优先级running > 其他状态
const statusPriority = { running: 0 }
const aPriority = statusPriority[a.status] ?? 1
const bPriority = statusPriority[b.status] ?? 1
if (aPriority !== bPriority) {
return aPriority - bPriority
}
// 相同状态按updateTime降序
return (b.updateTime || 0) - (a.updateTime || 0)
})
}
// 获取排序后的任务列表(合并数据库和内存状态)
async function getSortedTasksList() {
const tasks = await fileTransferDB.findAsync({}, { sort: { updateTime: -1 } })
// 合并内存中的活跃任务状态
const tasksWithStatus = tasks.map(task => {
const activeTask = activeTasks.get(task.taskId)
if (activeTask) {
return {
...task,
status: activeTask.status,
progress: activeTask.progress,
speed: activeTask.speed,
eta: activeTask.eta,
errorMessage: activeTask.errorMessage
}
}
return task
})
// 按状态和时间重新排序
return sortTasks(tasksWithStatus)
}
module.exports = (httpServer) => {
const transferIo = new Server(httpServer, {
path: '/file-transfer',
cors: { origin: '*' }
})
let connectionCount = 0
const connectedSockets = new Set() // 跟踪所有连接的socket
transferIo.on('connection', (socket) => {
connectionCount++
connectedSockets.add(socket)
consola.success(`file-transfer websocket 已连接 - 当前连接数: ${ connectionCount }`)
// IP白名单检查
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
if (!isAllowedIp(requestIP)) {
socket.emit('ip_forbidden', 'IP地址不在白名单中')
socket.disconnect()
return
}
// 连接时发送当前所有任务状态
socket.on('get_tasks', async ({ token }) => {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
return
}
try {
const sortedTasks = await getSortedTasksList()
socket.emit('tasks_list', sortedTasks)
// 检查是否有运行中的任务,如果有则启动定时推送
const runningTasks = sortedTasks.filter(task => task.status === 'running')
if (runningTasks.length > 0) {
startProgressBroadcast(socket, token, requestIP)
}
} catch (error) {
socket.emit('error', { message: '获取任务列表失败', error: error.message })
}
})
// 启动传输任务
socket.on('start_transfer', async (transferConfig) => {
const { token } = transferConfig
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
return
}
try {
const { createTransferTask = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (!createTransferTask) throw new Error('Plus功能解锁失败: createTransferTask')
const task = await createTransferTask(transferConfig, socket, hostListDB, fileTransferDB, executeTransfer)
socket.emit('task_started', { taskId: task.taskId, message: '传输任务已启动' })
// 广播更新的任务列表
const updatedTasks = await getSortedTasksList()
socket.emit('tasks_list', updatedTasks)
} catch (error) {
consola.error('启动传输任务失败:', error)
socket.emit('task_failed', {
taskId: transferConfig.taskId,
message: error.message
})
}
})
// 取消任务
socket.on('cancel_task', async ({ taskId, token }) => {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
return
}
try {
await cancelTransferTask(taskId)
socket.emit('task_cancelled', { taskId, message: '任务已取消' })
} catch (error) {
socket.emit('error', { message: '取消任务失败', error: error.message })
}
})
// 重试任务
socket.on('retry_task', async ({ taskId, token }) => {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
return
}
try {
const task = await fileTransferDB.findOneAsync({ taskId })
if (!task) {
throw new Error('任务不存在')
}
// 重置任务状态
await fileTransferDB.updateAsync(
{ taskId },
{
$set: {
status: 'running',
progress: 0,
speed: 0,
errorMessage: null,
updateTime: Date.now()
}
}
)
// 重新启动任务(不创建新任务,重用现有任务)
executeTransfer(task, socket)
socket.emit('task_started', { taskId: task.taskId, message: '任务重试中' })
} catch (error) {
socket.emit('error', { message: '重试任务失败', error: error.message })
}
})
// 删除单个任务
socket.on('delete_task', async ({ taskId, token }) => {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
return
}
try {
const task = await fileTransferDB.findOneAsync({ taskId })
if (!task) {
throw new Error('任务不存在')
}
// 检查任务状态,不允许删除正在进行的任务
if (task.status === 'running') {
throw new Error('无法删除正在进行的任务,请先取消任务')
}
// 从数据库删除任务
await fileTransferDB.removeAsync({ taskId })
socket.emit('task_deleted', { taskId, message: '任务已删除' })
// 广播任务列表更新
const updatedTasks = await getSortedTasksList()
socket.emit('tasks_list', updatedTasks)
} catch (error) {
socket.emit('error', { message: '删除任务失败', error: error.message })
}
})
// 清空已完成任务
socket.on('clear_completed_tasks', async ({ token }) => {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
return
}
try {
// 删除所有已完成、失败、取消的任务
const result = await fileTransferDB.removeAsync(
{
status: { $in: ['completed', 'failed', 'cancelled'] }
},
{ multi: true }
)
socket.emit('tasks_cleared', {
count: result,
message: `已清空 ${ result } 个任务`
})
// 广播任务列表更新
const updatedTasks = await getSortedTasksList()
socket.emit('tasks_list', updatedTasks)
} catch (error) {
socket.emit('error', { message: '清空任务失败', error: error.message })
}
})
socket.on('disconnect', () => {
connectionCount--
connectedSockets.delete(socket)
// 清理该socket的进度广播定时器
stopProgressBroadcast(socket)
consola.info(`file-transfer websocket 断开连接 - 当前连接数: ${ connectionCount }`)
})
})
return transferIo
}
// 执行传输任务
async function executeTransfer(taskData, socket) {
const { taskId } = taskData
try {
// 更新任务状态为运行中确保updateTime是最新的
await updateTaskStatus(taskId, 'running', socket)
// 获取源主机连接配置
const sourceOptions = await getConnectionOptions(taskData.sourceHostId)
// 建立SSH连接到源主机
const { sshClient } = await connectToHost(sourceOptions)
// 注册活跃任务
activeTasks.set(taskId, {
sshClient,
status: 'running',
progress: 0,
speed: 0,
startTime: Date.now(),
keyFile: null, // 用于跟踪临时密钥文件
totalFiles: taskData.sourcePaths.length, // 初始化文件总数
sourcePaths: taskData.sourcePaths // 保存源文件路径信息
})
// 只支持Rsync传输
if (taskData.method === 'rsync') {
await executeRsyncTransfer(taskData, sshClient, socket)
} else {
throw new Error(`不支持的传输方法: ${ taskData.method },仅支持 rsync`)
}
// 传输完成
await updateTaskStatus(taskId, 'completed', socket)
} catch (error) {
consola.error(`传输任务 ${ taskId } 失败:`, error)
await updateTaskStatus(taskId, 'failed', socket, error.message)
} finally {
// 清理资源
const activeTask = activeTasks.get(taskId)
if (activeTask) {
if (activeTask.sshClient) {
activeTask.sshClient.end()
}
activeTasks.delete(taskId)
}
}
}
// Rsync传输实现
async function executeRsyncTransfer(taskData, sshClient, socket) {
const { taskId, sourcePaths, targetPath } = taskData
// 获取目标主机信息
const targetConnectionData = await getConnectionOptions(taskData.targetHostId)
const targetOptions = targetConnectionData.authInfo
const targetHostAuthType = targetOptions.password ? 'password' : 'privateKey'
consola.info(`目标主机认证方式: ${ targetHostAuthType }`)
// 构建Rsync命令
let rsyncCmd = []
let envVars = {}
// ssh密钥tmp路径
let keyFile = null
// 如果目标主机使用密码认证源主机需使用sshpass
if (targetHostAuthType === 'password') {
// 检查源主机sshpass是否可用
try {
await checkSshpassAvailable(sshClient)
// 使用环境变量方式传递密码,避免命令行参数解析问题
envVars.SSHPASS = targetOptions.password
rsyncCmd.push('sshpass', '-e') // -e 表示从环境变量读取密码
} catch (error) {
throw new Error('源主机未安装sshpass工具无法进行密码认证传输。请使用密钥认证或在源主机安装sshpass: apt-get install sshpass 或 yum install sshpass')
}
} else {
keyFile = await createRemoteTempKeyFile(sshClient, targetOptions.privateKey)
}
rsyncCmd.push('rsync', '-avz', '--progress', '--partial') // 归档、详细、压缩、进度、支持断点续传
// 添加增量同步和安全选项
rsyncCmd.push('--inplace', '--append') // 断点续传关键选项
// 添加更详细的进度输出选项
rsyncCmd.push('--stats', '--human-readable', '--itemize-changes') // 统计信息、可读格式、详细变更
// 构建SSH命令选项
const sshOptions = [
'-p', (targetOptions.port || 22).toString(),
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'GlobalKnownHostsFile=/dev/null'
]
// 根据认证类型设置不同的SSH选项
if (targetOptions.password) {
sshOptions.push('-o', 'PreferredAuthentications=password')
} else {
sshOptions.push('-o', 'BatchMode=yes')
}
if (keyFile) {
sshOptions.push('-i', `"${ keyFile }"`)
// 记录临时密钥文件路径到活跃任务中
const activeTask = activeTasks.get(taskId)
if (activeTask) {
activeTask.keyFile = keyFile
}
}
// 封装成一个整体的 SSH 命令,放到双引号内,确保 rsync 正确解析
const sshCmd = `ssh ${ sshOptions.join(' ') } -o LogLevel=ERROR`
rsyncCmd.push('-e', `"${ sshCmd }"`)
// 添加传输选项
if (taskData.options.delete) {
rsyncCmd.push('--delete')
}
if (taskData.options.excludePatterns && taskData.options.excludePatterns.length > 0) {
taskData.options.excludePatterns.forEach(pattern => {
rsyncCmd.push('--exclude', pattern)
})
}
// 添加源和目标路径
rsyncCmd.push(...sourcePaths.map(item => item.path))
rsyncCmd.push(`${ targetOptions.username }@${ targetOptions.host }:"${ targetPath }"`)
consola.info(`执行Rsync命令: ${ rsyncCmd.join(' ') }`)
if (Object.keys(envVars).length > 0) {
consola.info(`环境变量: ${ Object.keys(envVars).join(', ') }`)
}
return new Promise((resolve, reject) => {
// 在源主机上执行Rsync命令
let finalCommand = rsyncCmd.join(' ')
// 环境变量,需要在命令前设置
if (Object.keys(envVars).length > 0) {
const envString = Object.entries(envVars)
.map(([key, value]) => `${ key }='${ value.replace(/'/g, '\'"\'"\'') }'`)
.join(' ')
finalCommand = `${ envString } ${ finalCommand }`
}
// consola.info(`最终Rsync命令: ${ finalCommand }`)
let start = false
sshClient.exec(finalCommand, (err, stream) => {
if (err) {
reject(err)
return
}
let errorOutput = ''
const activeTask = activeTasks.get(taskId)
stream.on('close', async (code) => {
if (code === 0) {
// 传输成功,设置为校验状态
const activeTask = activeTasks.get(taskId)
if (activeTask && activeTask.progressTracker) {
activeTask.progressTracker.isVerifying = true
await updateTaskProgress(taskId, activeTask.progressTracker, socket)
}
consola.success(`Rsync传输完成: ${ taskId }`)
resolve()
} else {
consola.error(`Rsync传输失败: ${ taskId }, 退出码: ${ code }`)
reject(new Error(`Rsync传输失败: ${ errorOutput || '未知错误' }`))
}
})
stream.on('data', (data) => {
if (!start) {
start = true
// 清理密钥文件并从内存中移除
if (activeTask.keyFile) {
cleanupRemoteKeyFile(activeTask.sshClient, activeTask.keyFile)
}
return
}
fileTransferThrottle(parseRsyncProgress(data.toString(), taskId, socket))
})
stream.stderr.on('data', (data) => {
if (!start) {
start = true
// 清理密钥文件并从内存中移除
if (activeTask.keyFile) {
cleanupRemoteKeyFile(activeTask.sshClient, activeTask.keyFile)
}
return
}
const output = data.toString()
errorOutput += output
consola.warn(`Rsync stderr: ${ output }`)
// 解析错误信息中的进度信息
fileTransferThrottle(parseRsyncProgress(output, taskId, socket))
})
// 存储stream引用以支持取消操作
if (activeTask) {
activeTask.stream = stream
}
})
})
}
// 解析Rsync进度
function parseRsyncProgress(output, taskId, socket) {
const activeTask = activeTasks.get(taskId)
if (!activeTask) return
// 初始化进度跟踪器(如果不存在)
if (!activeTask.progressTracker) {
activeTask.progressTracker = {
totalFiles: activeTask.totalFiles || 1,
completedFiles: 0,
currentFile: null,
files: new Map(), // 文件路径 -> 进度信息
overallProgress: 0,
isVerifying: false
}
}
const tracker = activeTask.progressTracker
const outputLine = output.trim()
// 添加调试日志
consola.info(`Rsync输出 [${ taskId }]: "${ outputLine }"`)
// 检测是否在校验阶段
if (outputLine.includes('verifying') ||
outputLine.includes('checking') ||
outputLine.includes('delta-transmission disabled') ||
(tracker.overallProgress >= 100 && outputLine.includes('receiving'))) {
if (!tracker.isVerifying) {
tracker.isVerifying = true
updateTaskProgress(taskId, tracker, socket)
}
return
}
// 如果还没有当前文件且总文件数为1使用第一个源文件作为当前文件
if (!tracker.currentFile && tracker.totalFiles === 1) {
const activeTask = activeTasks.get(taskId)
if (activeTask && activeTask.sourcePaths && activeTask.sourcePaths.length > 0) {
const sourcePath = activeTask.sourcePaths[0].path
const fileName = sourcePath.split('/').pop()
tracker.currentFile = fileName || sourcePath
if (!tracker.files.has(tracker.currentFile)) {
tracker.files.set(tracker.currentFile, {
progress: 0,
size: activeTask.sourcePaths[0].size || 0,
transferred: 0,
speed: 0,
status: 'transferring'
})
}
consola.info(`单文件传输初始化: ${ tracker.currentFile }`)
}
}
// 检测新文件开始传输
// 模式1: itemize-changes 格式 - <f+++++++++ filename
let filePathMatch = outputLine.match(/^<f\+{5,}\s+(.+)$/)
if (filePathMatch) {
const filePath = filePathMatch[1].trim()
tracker.currentFile = filePath
if (!tracker.files.has(filePath)) {
tracker.files.set(filePath, {
progress: 0,
size: 0,
transferred: 0,
speed: 0,
status: 'transferring'
})
}
consola.info(`检测到新文件传输(itemize格式): ${ filePath }`)
return
}
// 模式2: 传统文件路径格式
filePathMatch = outputLine.match(/^([^\s]+\/[^\s]+|[^\s]+\.[^\s]+)\s*$/)
if (filePathMatch && !outputLine.includes('%')) {
const filePath = filePathMatch[1]
tracker.currentFile = filePath
if (!tracker.files.has(filePath)) {
tracker.files.set(filePath, {
progress: 0,
size: 0,
transferred: 0,
speed: 0,
status: 'transferring'
})
}
consola.info(`检测到新文件传输(传统格式): ${ filePath }`)
return
}
// 解析进度信息
let fileProgress = null
let speed = 0
let eta = 0
let transferred = 0
// 模式1: 标准格式 - 1,024,000 100% 1.23MB/s 0:00:30 (xfr#1, to-chk=0/1)
let match = outputLine.match(/(\d+(?:,\d+)*)\s+(\d+)%\s+([\d.]+)([KMGT]?B\/s)\s+(\d+):(\d+):(\d+)/)
if (match) {
const [, transferredStr, percentage, speedVal, speedUnit, hours, minutes, seconds] = match
fileProgress = parseInt(percentage)
transferred = parseInt(transferredStr.replace(/,/g, ''))
const unitFactor = { 'B/s': 1, 'KB/s': 1024, 'MB/s': 1024 ** 2, 'GB/s': 1024 ** 3, 'TB/s': 1024 ** 4 }
speed = parseFloat(speedVal) * (unitFactor[speedUnit] || 1)
eta = parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds)
consola.info(`Rsync标准格式解析 [${ taskId }]: ${ fileProgress }%, 速度: ${ speedVal }${ speedUnit }, ETA: ${ eta }s`)
} else {
// 模式2: 简化格式 - 100% 1.23MB/s
match = outputLine.match(/(\d+)%\s+([\d.]+)([KMGT]?B\/s)/)
if (match) {
fileProgress = parseInt(match[1])
const unit = match[3]
const unitFactor = { 'B/s': 1, 'KB/s': 1024, 'MB/s': 1024 ** 2, 'GB/s': 1024 ** 3, 'TB/s': 1024 ** 4 }
speed = parseFloat(match[2]) * (unitFactor[unit] || 1)
consola.info(`Rsync简化格式解析 [${ taskId }]: ${ fileProgress }%, 速度: ${ match[2] }${ unit }`)
} else {
// 模式3: 最简格式 - 只有百分比
match = outputLine.match(/(\d+)%/)
if (match) {
fileProgress = parseInt(match[1])
consola.info(`Rsync百分比解析 [${ taskId }]: ${ fileProgress }%`)
}
}
}
// 检测文件传输完成 - to-chk=x/y 格式
const checkMatch = outputLine.match(/to-chk=(\d+)\/(\d+)/)
if (checkMatch) {
const [, remaining, total] = checkMatch
const completed = parseInt(total) - parseInt(remaining)
tracker.completedFiles = completed
tracker.totalFiles = parseInt(total)
// 如果当前文件进度是100%,标记为完成并清除当前文件
if (tracker.currentFile && fileProgress === 100) {
const fileInfo = tracker.files.get(tracker.currentFile)
if (fileInfo) {
fileInfo.progress = 100
fileInfo.status = 'completed'
fileInfo.transferred = transferred
// 清除当前文件,为下一个文件做准备
consola.info(`文件传输完成: ${ tracker.currentFile }`)
tracker.currentFile = null
}
}
consola.info(`传输进度: ${ completed }/${ total } 文件完成`)
}
// 更新当前文件进度
if (fileProgress !== null) {
// 如果没有当前文件,但有进度信息,尝试找到正在传输的文件
if (!tracker.currentFile && tracker.files.size > 0) {
// 找到最后一个未完成的文件作为当前文件
for (const [fileName, fileInfo] of tracker.files.entries()) {
if (fileInfo.status === 'transferring' && fileInfo.progress < 100) {
tracker.currentFile = fileName
break
}
}
}
// 更新当前文件进度
if (tracker.currentFile) {
const fileInfo = tracker.files.get(tracker.currentFile)
if (fileInfo) {
fileInfo.progress = fileProgress
fileInfo.speed = speed
fileInfo.transferred = transferred
if (fileProgress === 100) {
fileInfo.status = 'completed'
// 文件完成后清除当前文件,为下一个文件做准备
consola.info(`文件传输完成: ${ tracker.currentFile }`)
tracker.currentFile = null
}
}
} else {
// 如果仍然没有当前文件,记录警告但继续处理
consola.warn(`收到进度信息但没有当前文件 [${ taskId }]: ${ fileProgress }%`)
}
}
// 计算总体进度
if (tracker.totalFiles > 0) {
if (tracker.totalFiles === 1) {
// 单文件传输:直接使用当前文件进度
if (tracker.currentFile && tracker.files.has(tracker.currentFile)) {
const currentFileInfo = tracker.files.get(tracker.currentFile)
tracker.overallProgress = currentFileInfo ? currentFileInfo.progress : 0
} else {
// 如果没有文件信息使用解析出的fileProgress
tracker.overallProgress = fileProgress || 0
}
} else {
// 多文件传输:基于已完成文件数 + 当前文件进度的总体进度
let overallProgress = (tracker.completedFiles / tracker.totalFiles) * 100
if (tracker.currentFile && tracker.files.has(tracker.currentFile)) {
const currentFileInfo = tracker.files.get(tracker.currentFile)
if (currentFileInfo && currentFileInfo.status !== 'completed') {
overallProgress += (currentFileInfo.progress / tracker.totalFiles)
}
}
tracker.overallProgress = Math.min(100, Math.round(overallProgress))
}
}
// 更新活跃任务状态
activeTask.progress = tracker.overallProgress
activeTask.speed = speed
updateTaskProgress(taskId, tracker, socket)
}
// 更新任务进度
async function updateTaskProgress(taskId, progressTracker, socket) {
try {
// 准备数据库更新数据
const updateData = {
progress: progressTracker.overallProgress,
updateTime: Date.now()
}
// 如果在校验阶段,添加状态
if (progressTracker.isVerifying) {
updateData.status = 'verifying'
}
await fileTransferDB.updateAsync(
{ taskId },
{ $set: updateData }
)
// 准备发送给前端的详细进度数据
const activeTask = activeTasks.get(taskId)
const progressData = {
taskId,
overallProgress: progressTracker.overallProgress,
completedFiles: progressTracker.completedFiles,
totalFiles: progressTracker.totalFiles,
currentFile: progressTracker.currentFile,
isVerifying: progressTracker.isVerifying,
speed: activeTask ? activeTask.speed : 0,
files: Array.from(progressTracker.files.entries()).map(([path, info]) => ({
path,
progress: info.progress,
status: info.status,
speed: info.speed,
transferred: info.transferred
}))
}
consola.info(`推送进度更新 [${ taskId }]:`, {
overall: progressData.overallProgress,
files: `${ progressData.completedFiles }/${ progressData.totalFiles }`,
current: progressData.currentFile,
speed: `${ (progressData.speed / 1024 / 1024).toFixed(1) }MB/s`,
verifying: progressData.isVerifying
})
// 发送给原始socket如果存在且连接
if (socket && socket.connected) {
socket.emit('task_progress', progressData)
}
} catch (error) {
consola.error('更新任务进度失败:', error)
}
}
// 更新任务状态
async function updateTaskStatus(taskId, status, socket, errorMessage = null) {
try {
const updateData = {
status,
updateTime: Date.now()
}
if (errorMessage) {
updateData.errorMessage = errorMessage
}
await fileTransferDB.updateAsync({ taskId }, { $set: updateData })
// 更新内存中的状态
const activeTask = activeTasks.get(taskId)
if (activeTask) {
activeTask.status = status
if (errorMessage) {
activeTask.errorMessage = errorMessage
}
}
// 通知前端
socket.emit('task_status_changed', {
taskId,
status,
errorMessage
})
} catch (error) {
consola.error('更新任务状态失败:', error)
}
}
// 取消传输任务
async function cancelTransferTask(taskId) {
const activeTask = activeTasks.get(taskId)
if (activeTask) {
// 终止SSH连接
if (activeTask.sshClient) {
activeTask.sshClient.end()
}
activeTasks.delete(taskId)
}
// 更新数据库状态
await fileTransferDB.updateAsync(
{ taskId },
{
$set: {
status: 'cancelled',
updateTime: Date.now()
}
}
)
}
// 连接到主机(文件传输专用,直连不走代理)
async function connectToHost(connectionOptions) {
return new Promise((resolve, reject) => {
const sshClient = new SSHClient()
sshClient.on('ready', () => {
resolve({ sshClient })
})
sshClient.on('error', (err) => {
reject(err)
})
// 直接连接,不使用代理
sshClient.connect(connectionOptions.authInfo)
})
}
// 检查sshpass工具是否可用
async function checkSshpassAvailable(sshClient) {
return new Promise((resolve, reject) => {
sshClient.exec('which sshpass', (err, stream) => {
if (err) {
reject(err)
return
}
let output = ''
stream.on('close', (code) => {
if (code === 0 && output.trim()) {
resolve(true)
} else {
reject(new Error('sshpass not found'))
}
})
stream.on('data', (data) => {
output += data.toString()
})
stream.stderr.on('data', () => {
// 忽略stderr
})
})
})
}
// 在源主机上创建临时密钥文件
async function createRemoteTempKeyFile(sshClient, privateKey) {
return new Promise((resolve, reject) => {
const remotePath = `/tmp/easynode_key_${ Date.now() }_${ Math.random().toString(36).slice(2) }`
sshClient.sftp((err, sftp) => {
if (err) return reject(err)
// 打开文件句柄
sftp.open(remotePath, 'w', 0o600, (openErr, handle) => {
if (openErr) {
sftp.end()
return reject(openErr)
}
// 写入私钥内容Buffer
const keyBuffer = Buffer.from(privateKey, 'utf-8')
sftp.write(handle, keyBuffer, 0, keyBuffer.length, 0, (writeErr) => {
if (writeErr) {
sftp.close(handle, () => sftp.end())
return reject(writeErr)
}
sftp.close(handle, (closeErr) => {
sftp.end()
if (closeErr) return reject(closeErr)
resolve(remotePath)
})
})
})
})
})
}
// 安全清理远程密钥文件
function cleanupRemoteKeyFile(sshClient, keyFile) {
if (!keyFile || !sshClient) return
consola.info(`清理远程密钥文件: ${ keyFile }`)
// 先删除文件,再验证删除
// const cleanupCmd = `rm -f "${ keyFile }" && if [ -f "${ keyFile }" ]; then echo "CLEANUP_FAILED"; else echo "CLEANUP_SUCCESS"; fi`
const cleanupCmd = 'cd /tmp && rm -f easynode_key_* && if ls easynode_key_* 2>/dev/null; then echo "CLEANUP_FAILED"; else echo "CLEANUP_SUCCESS"; fi'
sshClient.exec(cleanupCmd, (err, stream) => {
if (err) {
consola.error('清理密钥文件时SSH错误:', err)
return
}
let output = ''
stream.on('data', (data) => {
output += data.toString()
})
stream.on('close', () => {
if (output.includes('CLEANUP_SUCCESS')) {
consola.success(`密钥文件清理成功: ${ keyFile }`)
} else if (output.includes('CLEANUP_FAILED')) {
consola.error(`密钥文件清理失败: ${ keyFile }`)
// 强制清理尝试 - 覆盖后删除
const forceCleanup = `echo "" > "${ keyFile }" && rm -f "${ keyFile }"`
sshClient.exec(forceCleanup, () => {
consola.info(`强制清理密钥文件: ${ keyFile }`)
})
} else {
consola.warn(`密钥文件清理状态未知: ${ keyFile }`)
}
})
stream.stderr.on('data', (data) => {
consola.warn('清理密钥文件stderr:', data.toString())
})
})
}
// 定时进度广播管理
const progressBroadcastTimers = new Map() // socket -> timer
// 启动进度广播
function startProgressBroadcast(socket, token, requestIP) {
// 如果该socket已经有定时器先清除
if (progressBroadcastTimers.has(socket)) {
clearInterval(progressBroadcastTimers.get(socket))
}
// 启动新的定时器,每秒检查并推送运行中任务的进度
const timer = setInterval(async () => {
try {
// 验证token是否仍然有效
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
// token失效停止广播
stopProgressBroadcast(socket)
return
}
// 检查是否还有运行中的任务
const runningTasks = Array.from(activeTasks.values()).filter(task => task.status === 'running')
if (runningTasks.length === 0) {
// 没有运行中的任务,停止广播
consola.info('没有运行中的任务,停止进度广播')
stopProgressBroadcast(socket)
return
}
// 推送所有运行中任务的最新进度
for (const [taskId, activeTask] of activeTasks.entries()) {
if (activeTask.status === 'running' && activeTask.progressTracker) {
const progressData = {
taskId,
overallProgress: activeTask.progressTracker.overallProgress || 0,
completedFiles: activeTask.progressTracker.completedFiles || 0,
totalFiles: activeTask.progressTracker.totalFiles || 1,
currentFile: activeTask.progressTracker.currentFile,
isVerifying: activeTask.progressTracker.isVerifying || false,
speed: activeTask.speed || 0
}
// 检查socket是否仍然连接
if (socket.connected) {
socket.emit('task_progress', progressData)
} else {
// socket已断开停止广播
stopProgressBroadcast(socket)
return
}
}
}
} catch (error) {
consola.error('进度广播出错:', error)
stopProgressBroadcast(socket)
}
}, 1500)
progressBroadcastTimers.set(socket, timer)
consola.info('已启动进度广播定时器')
}
// 停止进度广播
function stopProgressBroadcast(socket) {
const timer = progressBroadcastTimers.get(socket)
if (timer) {
clearInterval(timer)
progressBroadcastTimers.delete(socket)
consola.info('已停止进度广播定时器')
}
}

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,8 @@ const {
aiConfigDBPath,
chatHistoryDBPath,
favoriteSftpDBPath,
proxyDBPath
proxyDBPath,
fileTransferDBPath
} = require('../config')
module.exports.KeyDB = class KeyDB {
@@ -189,4 +190,15 @@ module.exports.ProxyDB = class ProxyDB {
getInstance() {
return ProxyDB.instance
}
}
module.exports.FileTransferDB = class FileTransferDB {
constructor() {
if (!FileTransferDB.instance) {
FileTransferDB.instance = new Datastore({ filename: fileTransferDBPath, autoload: true })
}
}
getInstance() {
return FileTransferDB.instance
}
}

View File

@@ -1,8 +1,9 @@
module.exports = {
plusServers: [
'https://en1.221022.xyz',
'https://en2.221022.xyz',
'https://en1-plus-active-tencent-eo.chaoszhu.com',
'https://en2-plus-active-tencent-eo.chaoszhu.com',
'https://en1.221022.xyz',
'https://en2.221022.xyz'
]
}

View File

@@ -1 +1 @@
U2FsdGVkX19ypJFyeFxNF22xekv1FOR0XT1ofsw1MQ8fs/xIpP4rByqxDo2e7/E4w3sSaVpT3IUt5jH0alt4GeryiM3wfedmo+4eBiw2qTTBW0tQHCbh2tIIxJWPv3NdaMCv0fEzJNJf2CrhgrRSv9RpUpDYYojpg50wnKjL2Fiw37mGw9uqHowmgqoyxO14Otjbh9Sf2Y9DqoYr4SF/uvxPGeFpBO3op/+8gnqgN3r0mmF8DLn73m5a7NANNe6IuHXCwfu3DY9q9xgI6OEp2ecW/rxXijGRllMmVkdSKeG0xzSeV7q1PF+qpvEo9xz8FdVBOW4Te6G/hDpwA3WI0cwKY5nOs+aY4J8KNmtpsQnUMbjm9w5OjCrKOBzyQ6JIYbT6b7vP4jyF6FLOR0DJJ3JRP1OGbDLZhoWkXtNB7Mwa85KegKVx3Hg2eEsEug93jQrymm++YtFvoEbdAD5AeA8NX/qJRVqloOikv4d/mCy1BJaAQE4tAHNIldwMCvTarUKrYSxjdzO0sZmRuE4PhwjIGIN5dK8/GYR9vFfnGuNhM+w0gRJr1VDK+/6XnMp0PmQMx7MYO4UE2A4zulIsxhWxYRhzvmePPWf5rOIPnSl8dYx/otqgs6LY+FDkMWa99itdFFhCZxjapUJ2917ofYuAH8Y76QliRVgZ9HOPgpJOr/A02dt3c+Kp/p9cu6vAuGJ29l9JDEr1d35+/hRvvR6SEWYoalFEjkyEl4Y1cuxyNasAxkpxFlN9/ROl1D3qDk1L/lci4YC1aXlyW8jLyNthIXFMHWTiawA7xEV0iFiSMjDkpIpv+1/V8T+b7djl13/ALfgw/Zw7rNDKNqj8vaYLNO2T7epyFeESWE8EY9Q=
U2FsdGVkX19cLtuM32n63Jkv0/ObLTjPu6qgg7GgYXu5azvBJiwxIKAZpdoIwwJyxqsRh5oggNeUZxVtLyQ2DPvBV8N+e4P9SCIZ4AwL0NoQzgwes1gjKAR6bfVxCJ1KjB2oSa+HQLtKc9grodpsF++KLNC8+D1ZQBWVQ7Florqr0Zn0Nq2v3tsO2wS22QEGnmtTQaGELMnBgNti1EuBk7XAcChOCIY3KSsELowIdcFyyAlouc/YJiholQELTc3R3li3GSlmOuFPTnnaLe/PBMxjbTApma2otVTl5ddNkKzMrMSyOhBprw1nOVK2k/sHhCcZtoVBp3iT/13Dv50W2YTAw8k9Tb52w1jcn+fDIcj1EBZNOCTUhJ7TJXgncFCvNuRWKwFrx3vHJWW3Qaep/PMRbv+UYfWnU56kXBldOMTeyvIK4GRrm109fSBAf3/Yrt1MquIkt6GLN5x3eF9p/CwSF5ObykkVa4A7mI10wRcY9Le/HyZbcbdkyICA8Y9+ZM9PAOP1rO7W268NJzc//y+tBH9Lh9X7beK6xR0soMkGsboAsGCSQGG8kLSlCtYSN2dWp2GqNrtgrMjqX2STri+/2PTdJ3rQFoABlIl24qLoZsgcsfiEm3MGnTcSCl2sbnJM0MSCdlTCrozmm9h7yFzrdKwxQamtzGd9UYzFzBUqGMmdXj0MxG0Wt8rGJ+ALM5NWJPV9JdXzsVGTMJcuUUole/ZUAnoeOyVPZGw9u2zve1G6DqxTlt7lTdqt+r7YQs8xVqKzNWhrs4JaHN8VC5vpCDAzPxukPfd7spfLqWn97llTP5zR/b4DiM5vwTuWgHgeXp4s3Ii2hgJW7yim8cWk7Q5phP1mvb0PorlSp+4=

View File

@@ -259,6 +259,17 @@ let shellThrottle = (fn, delay = 1000) => {
return throttled
}
const fileTransferThrottle = (fn, delay = 1500) => {
let lastCall = 0
return function (...args) {
const now = Date.now()
if (now - lastCall >= delay) {
lastCall = now
fn(...args)
}
}
}
const isProd = () => {
const EXEC_ENV = process.env.EXEC_ENV || 'production'
return EXEC_ENV === 'production'
@@ -364,6 +375,7 @@ module.exports = {
formatTimestamp,
resolvePath,
shellThrottle,
fileTransferThrottle,
isProd,
isAllowedIp,
ping,

View File

@@ -1,6 +1,6 @@
{
"name": "server",
"version": "3.3.0",
"version": "3.4.0",
"description": "easynode-server",
"bin": "./bin/www",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.3.0",
"version": "3.4.0",
"description": "easynode-web",
"private": true,
"scripts": {

View File

@@ -11,4 +11,8 @@
.el-collapse {
border-bottom-width: 0px;
}
.el-loading-mask {
z-index: 99!important;
}

View File

@@ -0,0 +1,260 @@
<template>
<div class="sftp_panel_container">
<div class="panel_header">
<div class="server_info">
<PlusSupportTip>
<server-selector
v-model="selectedServerId"
@change="onServerChange"
/>
</PlusSupportTip>
</div>
<div class="connection_status">
<el-tag
v-if="connectionStatus === 'connected'"
type="success"
size="small"
disable-transitions
>
已连接
</el-tag>
<el-tag
v-else-if="connectionStatus === 'connecting'"
type="warning"
size="small"
disable-transitions
>
连接中
</el-tag>
<el-tag
v-else-if="connectionStatus === 'failed'"
type="danger"
size="small"
disable-transitions
>
连接断开
</el-tag>
<el-tag
v-else
type="info"
size="small"
disable-transitions
>
未连接
</el-tag>
</div>
</div>
<!-- SFTP内容区域 -->
<div class="panel_content">
<template v-if="selectedServerId">
<!-- SFTP组件总是渲染但可能被遮罩层覆盖 -->
<div class="sftp_wrapper" :class="{ 'is-loading': connectionStatus !== 'connected' }">
<sftp-v2
ref="sftpRef"
:key="selectedServerId"
:host-id="selectedServerId"
:show-cd-command="false"
@exec-script="$emit('exec-script', $event)"
/>
</div>
</template>
<template v-else>
<div class="empty_state">
<el-icon class="empty_icon"><Monitor /></el-icon>
<p>请选择一台服务器</p>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed, getCurrentInstance } from 'vue'
import { Monitor } from '@element-plus/icons-vue'
import ServerSelector from '@/components/server-selector.vue'
import SftpV2 from '@/views/terminal/components/sftp-v2.vue'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const { proxy: { $store } } = getCurrentInstance()
const props = defineProps({
panelSide: {
type: String,
required: true // 'left' | 'right'
}
})
const emit = defineEmits(['server-change', 'exec-script', 'files-select',])
const selectedServerId = ref('')
const connectionStatus = ref('disconnected') // disconnected, connecting, connected, failed
const sftpRef = ref(null)
const isDark = computed(() => $store.isDark)
const onServerChange = (server) => {
if (server) {
connectionStatus.value = 'connecting'
emit('server-change', {
side: props.panelSide,
server
})
} else {
connectionStatus.value = 'disconnected'
emit('server-change', {
side: props.panelSide,
server: null
})
}
}
// 获取当前路径
const getCurrentPath = () => {
const currentPath = sftpRef.value?.currentPath
return (typeof currentPath === 'function' ? currentPath() : currentPath) || '/'
}
// 获取选中的文件
const getSelectedFiles = () => {
const selectedRows = sftpRef.value?.selectedRows
return (typeof selectedRows === 'function' ? selectedRows() : selectedRows) || []
}
// 刷新目录
const refresh = () => {
if (sftpRef.value && typeof sftpRef.value.refresh === 'function') {
sftpRef.value.refresh()
}
}
// 监听服务器选择变化
watch(selectedServerId, (newServerId) => {
if (newServerId) {
connectionStatus.value = 'connecting'
} else {
connectionStatus.value = 'disconnected'
}
})
// 监听SFTP组件的连接状态变化
watch(() => {
const statusRef = sftpRef.value?.connectionStatus
return typeof statusRef === 'function' ? statusRef() : statusRef
}, (newStatus) => {
if (newStatus && newStatus !== connectionStatus.value) {
console.log('SFTP连接状态变化:', newStatus)
connectionStatus.value = newStatus
}
}, { immediate: true })
// 暴露方法供父组件调用
defineExpose({
getCurrentPath,
getSelectedFiles,
refresh,
selectedServerId: computed(() => selectedServerId.value),
connectionStatus: computed(() => connectionStatus.value)
})
</script>
<style lang="scss" scoped>
.sftp_panel_container {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--el-border-color);
border-radius: 6px;
overflow: hidden;
.panel_header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: var(--el-bg-color-page);
border-bottom: 1px solid var(--el-border-color);
.server_info {
flex: 1;
margin-right: 12px;
}
.connection_status {
flex-shrink: 0;
}
}
.panel_content {
flex: 1;
overflow: hidden;
position: relative;
.sftp_wrapper {
position: relative;
height: 100%;
width: 100%;
.connection-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: v-bind('isDark ? "rgba(0, 0, 0, 0.9)" : "rgba(255, 255, 255, 0.9)"');
backdrop-filter: v-bind('isDark ? "blur(2px)" : "blur(2px)"');
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
.overlay-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--el-text-color-secondary);
.loading-icon,
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.loading-icon {
color: var(--el-color-primary);
}
.error-icon {
color: var(--el-color-danger);
}
p {
margin: 0 0 16px 0;
font-size: 14px;
}
}
}
}
.empty_state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
color: var(--el-text-color-secondary);
.empty_icon {
font-size: 48px;
margin-bottom: 16px;
color: var(--el-color-info);
}
p {
margin: 0 0 16px 0;
font-size: 14px;
}
}
}
}
</style>

View File

@@ -0,0 +1,358 @@
<template>
<div class="transfer-preview">
<div class="preview-header">
<h3>传输预览</h3>
<el-tag :type="getMethodType(preview.method)" size="large">
{{ preview.method.toUpperCase() }}
</el-tag>
</div>
<div class="server-info">
<div class="server-card source">
<div class="server-header">
<el-icon><Upload /></el-icon>
<span>源服务器</span>
</div>
<div class="server-details">
<h4>{{ preview.sourceHost.name }}</h4>
<p>{{ preview.sourceHost.host }}</p>
</div>
</div>
<div class="transfer-arrow">
<el-icon><ArrowRight /></el-icon>
</div>
<div class="server-card target">
<div class="server-header">
<el-icon><Download /></el-icon>
<span>目标服务器</span>
</div>
<div class="server-details">
<h4>{{ preview.targetHost.name }}</h4>
<p>{{ preview.targetHost.host }}</p>
</div>
</div>
</div>
<div class="transfer-summary">
<div class="summary-stats">
<el-statistic title="文件数量" :value="preview.fileCount" suffix="个" />
<el-statistic title="文件夹数量" :value="preview.folderCount" suffix="个" />
<el-statistic title="总大小" :value="formatSize(preview.totalSize)" />
<el-statistic title="预计时间" :value="preview.estimatedTime" />
</div>
</div>
<div class="path-info">
<div class="path-section">
<h4>
<el-icon><Folder /></el-icon>
源文件/文件夹
</h4>
<div class="path-list">
<div
v-for="(path, index) in preview.sourcePaths"
:key="index"
class="path-item"
>
<el-icon>
<Folder v-if="path.type === 'd'" />
<Document v-else />
</el-icon>
<span class="path-name">{{ getFileName(path.path) }}</span>
<span class="path-full">{{ path.path }}</span>
<span class="path-size">{{ formatSize(path.size) }}</span>
</div>
</div>
</div>
<div class="path-section">
<h4>
<el-icon><Folder /></el-icon>
目标路径
</h4>
<div class="target-path">
<el-icon><Folder /></el-icon>
<span>{{ preview.targetPath }}</span>
</div>
</div>
</div>
<div class="method-info">
<h4>传输方法说明</h4>
<div class="method-description">
<template v-if="preview.method === 'scp'">
<el-alert
type="info"
:closable="false"
show-icon
>
<template #title>
SCP (Secure Copy Protocol)
</template>
<p> 基于SSH协议的安全文件传输</p>
<p> 适合传输小到中等大小的文件</p>
<p> 简单快速但不支持增量传输</p>
<p> 传输过程中会保持文件权限和时间戳</p>
</el-alert>
</template>
<template v-else-if="preview.method === 'rsync'">
<el-alert
type="success"
:closable="false"
show-icon
>
<template #title>
Rsync (Remote Sync)
</template>
<p> 高效的增量同步工具</p>
<p> 支持断点续传和增量更新</p>
<p> 适合传输大文件或进行目录同步</p>
<p> 内置压缩和进度显示功能</p>
</el-alert>
</template>
</div>
</div>
<div class="warning-notice">
<el-alert
type="warning"
:closable="false"
show-icon
>
<template #title>注意事项</template>
<p> 传输过程中请确保网络连接稳定</p>
<p> 目标路径如果存在同名文件将会被覆盖</p>
<p> 传输大文件时建议使用Rsync方法</p>
<p> 传输过程可在任务管理器中查看进度</p>
</el-alert>
</div>
</div>
</template>
<script setup>
import { Upload, Download, ArrowRight, Folder, Document } from '@element-plus/icons-vue'
const props = defineProps({
preview: {
type: Object,
required: true
}
})
const emit = defineEmits(['confirm', 'cancel'])
const getMethodType = (method) => {
return method === 'rsync' ? 'success' : 'primary'
}
const getFileName = (fullPath) => {
return fullPath.split('/').pop() || fullPath
}
const formatSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
</script>
<style lang="scss" scoped>
.transfer-preview {
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h3 {
margin: 0;
color: var(--el-text-color-primary);
}
}
.server-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding: 20px;
background-color: var(--el-bg-color-page);
border-radius: 8px;
.server-card {
flex: 1;
text-align: center;
.server-header {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
font-size: 14px;
color: var(--el-text-color-regular);
.el-icon {
font-size: 16px;
}
}
.server-details {
h4 {
margin: 0 0 4px 0;
font-size: 16px;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
&.source .server-header .el-icon {
color: var(--el-color-warning);
}
&.target .server-header .el-icon {
color: var(--el-color-success);
}
}
.transfer-arrow {
margin: 0 20px;
font-size: 24px;
color: var(--el-color-primary);
}
}
.transfer-summary {
margin-bottom: 24px;
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
padding: 16px;
background-color: var(--el-bg-color-page);
border-radius: 6px;
}
}
.path-info {
margin-bottom: 24px;
.path-section {
margin-bottom: 20px;
h4 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px 0;
font-size: 14px;
color: var(--el-text-color-primary);
}
.path-list {
border: 1px solid var(--el-border-color);
border-radius: 6px;
max-height: 200px;
overflow-y: auto;
.path-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.el-icon {
color: var(--el-color-primary);
flex-shrink: 0;
}
.path-name {
font-weight: 500;
color: var(--el-text-color-primary);
min-width: 100px;
}
.path-full {
flex: 1;
font-size: 12px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-size {
font-size: 12px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
}
}
.target-path {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background-color: var(--el-bg-color-page);
border: 1px solid var(--el-border-color);
border-radius: 6px;
font-family: monospace;
.el-icon {
color: var(--el-color-primary);
}
}
}
}
.method-info {
margin-bottom: 24px;
h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--el-text-color-primary);
}
.method-description {
:deep(.el-alert__content) {
p {
margin: 2px 0;
font-size: 13px;
}
}
}
}
.warning-notice {
:deep(.el-alert__content) {
p {
margin: 2px 0;
font-size: 13px;
}
}
}
}
</style>

View File

@@ -0,0 +1,650 @@
<template>
<div class="transfer-task-manager">
<div class="task-filter">
<el-radio-group v-model="filterStatus" size="">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="running">传输中</el-radio-button>
<el-radio-button label="completed">传输完成</el-radio-button>
<el-radio-button label="failed">传输失败</el-radio-button>
<el-radio-button label="cancelled">已取消</el-radio-button>
</el-radio-group>
</div>
<div class="task-list">
<div v-if="filteredTasks.length === 0" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p>{{ filterStatus === 'all' ? '暂无传输任务' : `暂无${getStatusText(filterStatus)}的任务` }}</p>
</div>
<div
v-for="task in filteredTasks"
:key="task.taskId"
class="task-item"
:class="task.status"
>
<div class="task-header">
<div class="task-info">
<h4 class="task-title">
{{ task.sourceHostName }} {{ task.targetHostName }}
</h4>
<div class="task-meta">
<el-tag :type="getStatusType(task.status)" size="small">
{{ getStatusText(task.status) }}
</el-tag>
<span class="task-method">{{ task.method.toUpperCase() }}</span>
<span class="task-time">{{ formatDateTime(task.createTime) }}</span>
</div>
</div>
<div class="task-actions">
<el-button
v-if="task.status === 'running'"
size="small"
type="danger"
@click="$emit('cancel-task', task.taskId)"
>
取消
</el-button>
<el-button
v-if="task.status === 'failed'"
size="small"
type="primary"
@click="$emit('retry-task', task.taskId)"
>
重试
</el-button>
<el-button
v-if="task.status !== 'running'"
size="small"
type="danger"
plain
@click="deleteTask(task.taskId)"
>
删除
</el-button>
<el-button
size="small"
@click="toggleTaskDetails(task.taskId)"
>
{{ expandedTasks.has(task.taskId) ? '收起' : '详情' }}
</el-button>
</div>
</div>
<!-- 进度信息 -->
<div v-if="task.status === 'running'" class="task-progress">
<div class="progress-info">
<span v-if="task.isVerifying">
<el-icon class="verifying-icon"><Loading /></el-icon>
传输完成校验中...
</span>
<template v-else>
<span>总体进度: {{ task.progress || 0 }}%</span>
<span v-if="task.fileProgress">文件进度: {{ task.completedFiles || 0 }}/{{ task.totalFiles || 1 }}</span>
<span v-if="task.speed">速度: {{ formatSpeed(task.speed) }}</span>
</template>
</div>
<el-progress
:percentage="task.progress || 0"
:stroke-width="9"
:status="task.isVerifying ? 'warning' : ''"
striped
/>
<!-- 当前文件信息多文件传输时 -->
<div v-if="!task.isVerifying" class="current-file-info">
<div class="current-file-name">
<el-icon><Document /></el-icon>
<span>{{ getFileName(task.currentFile) || '...' }}</span>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="task.status === 'failed' && task.errorMessage" class="task-error">
<el-alert
:title="task.errorMessage"
type="error"
show-icon
:closable="false"
/>
</div>
<!-- 详细信息 -->
<el-collapse-transition>
<div v-if="expandedTasks.has(task.taskId)" class="task-details">
<div class="details-grid">
<div class="detail-item">
<label>任务ID:</label>
<span>{{ task.taskId }}</span>
</div>
<div class="detail-item">
<label>传输方法:</label>
<span>{{ task.method.toUpperCase() }}</span>
</div>
<div class="detail-item">
<label>源路径:</label>
<div class="path-list">
<div v-for="(path, index) in task.sourcePaths" :key="index" class="path-item">
<el-icon>
<Folder v-if="path.type === 'd'" />
<Document v-else />
</el-icon>
<span>{{ path.path }}</span>
<span class="file-size">({{ formatSize(path.size) }})</span>
</div>
</div>
</div>
<div class="detail-item">
<label>目标路径:</label>
<span>{{ task.targetPath }}</span>
</div>
<div class="detail-item">
<label>总大小:</label>
<span>{{ formatSize(task.totalSize) }}</span>
</div>
<div class="detail-item">
<label>创建时间:</label>
<span>{{ formatDateTime(task.createTime) }}</span>
</div>
<div v-if="task.updateTime !== task.createTime" class="detail-item">
<label>更新时间:</label>
<span>{{ formatDateTime(task.updateTime) }}</span>
</div>
</div>
<!-- 传输选项 -->
<div v-if="task.options && Object.keys(task.options).length > 0" class="transfer-options">
<h5>传输选项:</h5>
<div class="options-list">
<el-tag
v-for="(value, key) in task.options"
:key="key"
size="small"
:type="value ? 'success' : 'info'"
>
{{ getOptionName(key) }}: {{ value ? '是' : '否' }}
</el-tag>
</div>
</div>
</div>
</el-collapse-transition>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Document, Folder, Loading } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
const props = defineProps({
tasks: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['cancel-task', 'retry-task', 'delete-task', 'refresh',])
const filterStatus = ref('all')
const expandedTasks = ref(new Set())
// 计算属性
const filteredTasks = computed(() => {
if (filterStatus.value === 'all') {
return props.tasks
}
return props.tasks.filter(task => task.status === filterStatus.value)
})
const runningTasksCount = computed(() => {
return props.tasks.filter(task => task.status === 'running').length
})
const completedTasksCount = computed(() => {
return props.tasks.filter(task => task.status === 'completed').length
})
const failedTasksCount = computed(() => {
return props.tasks.filter(task => task.status === 'failed').length
})
// 方法
const toggleTaskDetails = (taskId) => {
if (expandedTasks.value.has(taskId)) {
expandedTasks.value.delete(taskId)
} else {
expandedTasks.value.add(taskId)
}
}
const deleteTask = async (taskId) => {
const task = props.tasks.find(t => t.taskId === taskId)
if (!task) return
try {
await ElMessageBox.confirm(
`确定要删除传输任务 "${ task.sourceHostName }${ task.targetHostName }" 吗?`,
'删除任务',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
emit('delete-task', taskId)
} catch {
// 用户取消删除
}
}
const getStatusType = (status) => {
const statusMap = {
running: 'primary',
completed: 'success',
failed: 'danger',
cancelled: 'info'
}
return statusMap[status] || 'info'
}
const getStatusText = (status) => {
const statusMap = {
running: '传输中',
completed: '传输完成',
failed: '传输失败',
cancelled: '已取消'
}
return statusMap[status] || status
}
const getOptionName = (key) => {
const optionMap = {
delete: '删除多余文件',
partial: '断点续传',
compress: '压缩传输'
}
return optionMap[key] || key
}
const formatDateTime = (timestamp) => {
if (!timestamp) return ''
const date = new Date(timestamp)
return date.toLocaleString('zh-CN')
}
const formatSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB',]
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${ size.toFixed(1) } ${ units[unitIndex] }`
}
const formatSpeed = (bytesPerSec) => {
if (!bytesPerSec || bytesPerSec === 0) return '0 B/s'
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s',]
let size = bytesPerSec
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${ size.toFixed(1) } ${ units[unitIndex] }`
}
const getFileName = (filePath) => {
if (!filePath) return ''
return filePath.split('/').pop() || filePath
}
</script>
<style lang="scss" scoped>
.transfer-task-manager {
.current-transfer {
margin-bottom: 20px;
padding: 16px;
background-color: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 8px;
.transfer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h4 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
}
}
.transfer-progress {
.overall-progress {
margin-bottom: 16px;
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
.verifying-status {
display: flex;
align-items: center;
gap: 4px;
color: var(--el-color-warning);
font-weight: 500;
.el-icon {
animation: rotate 1s linear infinite;
}
}
}
}
.files-progress {
.files-header {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.current-file {
margin-bottom: 12px;
padding: 8px;
background-color: var(--el-bg-color);
border-radius: 4px;
.file-info {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
font-size: 12px;
.file-name {
flex: 1;
color: var(--el-text-color-primary);
font-weight: 500;
}
.file-progress {
color: var(--el-color-primary);
font-weight: 500;
}
}
}
.completed-files {
.completed-file {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 11px;
.el-icon {
color: var(--el-color-success);
}
.file-name {
flex: 1;
color: var(--el-text-color-secondary);
}
.file-size {
color: var(--el-text-color-placeholder);
}
}
}
}
.transfer-details {
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
.task-stats {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px 16px;
background-color: var(--el-bg-color-page);
border-radius: 6px;
font-size: 14px;
.stat-item {
color: var(--el-text-color-secondary);
strong {
color: var(--el-text-color-primary);
margin: 0 2px;
}
}
}
.task-filter {
margin-bottom: 16px;
text-align: center;
}
.task-list {
max-height: 60vh;
overflow-y: auto;
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--el-text-color-secondary);
.empty-icon {
font-size: 48px;
color: var(--el-color-info-light-5);
margin-bottom: 12px;
}
}
.task-item {
border: 1px solid var(--el-border-color);
border-radius: 6px;
margin-bottom: 12px;
background-color: var(--el-bg-color);
&.running {
border-color: var(--el-color-primary-light-7);
background-color: var(--el-color-primary-light-9);
}
&.completed {
border-color: var(--el-color-success-light-7);
background-color: var(--el-color-success-light-9);
}
&.failed {
border-color: var(--el-color-danger-light-7);
background-color: var(--el-color-danger-light-9);
}
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
.task-info {
flex: 1;
min-width: 0;
.task-title {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.task-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
.task-method {
padding: 2px 6px;
background-color: var(--el-color-info-light-8);
border-radius: 4px;
}
}
}
.task-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.task-progress {
padding: 0 16px 16px;
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
.verifying-icon {
animation: rotate 1s linear infinite;
margin-right: 4px;
}
}
.current-file-info {
margin-top: 8px;
padding: 6px 8px;
background-color: var(--el-bg-color);
border-radius: 4px;
.current-file-name {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--el-text-color-secondary);
.el-icon {
color: var(--el-color-primary);
}
}
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.task-error {
padding: 0 16px 16px;
}
.task-details {
padding: 0 16px 16px;
border-top: 1px solid var(--el-border-color-light);
.details-grid {
display: grid;
gap: 12px;
margin: 16px 0;
.detail-item {
display: flex;
align-items: flex-start;
gap: 8px;
label {
min-width: 80px;
font-weight: 500;
color: var(--el-text-color-regular);
}
span {
flex: 1;
word-break: break-all;
}
.path-list {
flex: 1;
.path-item {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
font-size: 12px;
.file-size {
color: var(--el-text-color-secondary);
}
}
}
}
}
.transfer-options {
h5 {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--el-text-color-primary);
}
.options-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
}
}
}
}
}
</style>

View File

@@ -95,6 +95,7 @@ const basicFeatures = [
const plusFeatures = [
'AI Chat对话组件',
'服务器代理&跳板机功能',
'文件对传',
'终端单窗口模式',
'批量修改实例配置',
'脚本库批量导出导入',
@@ -102,7 +103,6 @@ const plusFeatures = [
'终端功能栏docker容器管理',
'凭据管理支持解密带密码保护的密钥',
'通知方式无限制',
'本地socket断开自动重连',
]
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div class="server-selector">
<el-select
v-model="selectedServer"
:disabled="!isPlusActive"
placeholder="选择服务器"
filterable
clearable
class="server-select"
@change="handleServerChange"
>
<el-option
v-for="server in serverList"
:key="server._id"
:label="`${server.name} (${server.host})`"
:value="server._id"
>
<div class="server-option">
<span class="server-name">{{ server.name }}</span>
<span class="server-host">{{ server.host }}</span>
</div>
</el-option>
</el-select>
</div>
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'change',])
const { proxy: { $store } } = getCurrentInstance()
const serverList = computed(() => $store.hostList?.filter(item => item.isConfig))
const isPlusActive = computed(() => $store.isPlusActive)
const selectedServer = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const handleServerChange = (serverId) => {
const server = serverList.value.find(s => s._id === serverId)
emit('change', server)
}
</script>
<style lang="scss" scoped>
.server-selector {
.server-select {
width: 100%;
}
.server-option {
display: flex;
justify-content: space-between;
align-items: center;
.server-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.server-host {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
</style>

View File

@@ -1,25 +1,500 @@
<template>
<div class="file_container">
<div class="file_header">
<div class="file_header_left">
<el-button type="primary">上传</el-button>
<el-button type="primary">下载</el-button>
<div class="file-transfer-container">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<div class="transfer-method-info">
<el-tag type="success" size="large">
在传输前需在服务器上安装好
<el-tooltip
content="问AI?"
raw-content
>
<span class="link" @click="handleAskAI">sshpassrsync</span>
</el-tooltip>
</el-tag>
<!-- <el-tooltip
content="一次性传输海量小文件例如node_modules、vendor、.m2、.gradle、.cache…<br>会让传输速度比蜗牛还慢, 且进度不可控<br>建议先压缩 → 再传输"
raw-content
>
<el-icon style="color: var(--el-color-warning);"><Warning /></el-icon>
</el-tooltip> -->
</div>
</div>
<div class="file_header_right">
<el-input v-model="search" placeholder="搜索" />
<div class="toolbar-right">
<el-button
type="primary"
:icon="List"
@click="showTaskManager"
>
任务管理
<el-badge
v-if="activeTaskCount > 0"
:value="activeTaskCount"
class="task-badge"
/>
</el-button>
<el-button @click="showTransferOptions = !showTransferOptions">
<el-icon><Setting /></el-icon>
传输选项
</el-button>
</div>
</div>
<!-- 传输选项面板 -->
<el-collapse-transition>
<div v-if="showTransferOptions" class="transfer-options">
<div class="options-content">
<h4>传输选项</h4>
<div class="options-grid">
<el-checkbox v-model="transferOptions.delete">
删除目标多余文件 (--delete)
</el-checkbox>
<el-checkbox v-model="transferOptions.partial">
支持断点续传 (--partial)
</el-checkbox>
<el-checkbox v-model="transferOptions.compress">
启用压缩传输 (-z)
</el-checkbox>
</div>
</div>
</div>
</el-collapse-transition>
<!-- 双面板布局 -->
<div class="dual-panel">
<!-- 左侧服务器面板 -->
<div class="panel left-panel">
<sftp-panel
ref="leftPanelRef"
panel-side="left"
@server-change="onServerChange"
@exec-script="$emit('exec-script', $event)"
/>
</div>
<!-- 中间传输控制区域 -->
<div class="transfer-controls">
<div class="transfer-buttons">
<el-button
type="primary"
size="large"
:disabled="!canTransferToRight"
:loading="isTransferring"
@click="transferToRight"
>
传输
<el-icon><ArrowRight /></el-icon>
</el-button>
<el-button
type="primary"
size="large"
style="margin-left: 0;"
:disabled="!canTransferToLeft"
:loading="isTransferring"
@click="transferToLeft"
>
<el-icon><ArrowLeft /></el-icon>
传输
</el-button>
</div>
</div>
<!-- 右侧服务器面板 -->
<div class="panel right-panel">
<sftp-panel
ref="rightPanelRef"
panel-side="right"
@server-change="onServerChange"
@exec-script="$emit('exec-script', $event)"
/>
</div>
</div>
<!-- 任务管理器对话框 -->
<el-dialog
v-model="showTaskDialog"
title="传输任务管理"
width="80%"
:close-on-click-modal="false"
>
<transfer-task-manager
:tasks="transferTasks"
@cancel-task="cancelTask"
@retry-task="retryTask"
@delete-task="deleteTask"
@refresh="refreshTasks"
/>
<template #footer>
<el-button @click="showTaskDialog = false">关闭</el-button>
<el-button type="danger" @click="clearCompletedTasks">清空任务列表</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue'
import { List, Setting, ArrowRight, ArrowLeft, Warning } from '@element-plus/icons-vue'
import socketIo from 'socket.io-client'
import SftpPanel from '@/components/file-transfer/sftp-panel.vue'
import TransferTaskManager from '@/components/file-transfer/transfer-task-manager.vue'
import { EventBus } from '@/utils'
const search = ref('')
defineEmits(['exec-script',])
const { proxy: { $store, $message, $serviceURI } } = getCurrentInstance()
// 传输配置仅支持Rsync
const transferOptions = ref({
delete: false,
partial: true,
compress: true
})
const showTransferOptions = ref(false)
// 服务器状态
const leftServer = ref(null)
const rightServer = ref(null)
const leftPanelRef = ref(null)
const rightPanelRef = ref(null)
// 传输状态
const isTransferring = ref(false)
// 任务管理
const transferTasks = ref([])
const showTaskDialog = ref(false)
const activeTaskCount = computed(() => {
return transferTasks.value.filter(task => task.status === 'running').length
})
// Socket连接
const socket = ref(null)
const token = computed(() => $store.token)
// 计算属性
const canTransferToRight = computed(() => {
return leftServer.value &&
rightServer.value &&
leftPanelRef.value?.connectionStatus === 'connected' &&
rightPanelRef.value?.connectionStatus === 'connected' &&
!isTransferring.value
})
const canTransferToLeft = computed(() => {
return leftServer.value &&
rightServer.value &&
leftPanelRef.value?.connectionStatus === 'connected' &&
rightPanelRef.value?.connectionStatus === 'connected' &&
!isTransferring.value
})
// 生命周期
onMounted(() => {
initializeSocket()
refreshTasks()
})
onUnmounted(() => {
if (socket.value) {
socket.value.disconnect()
}
})
// Socket连接
const initializeSocket = () => {
socket.value = socketIo($serviceURI, {
path: '/file-transfer',
forceNew: true
})
socket.value.on('connect', () => {
console.log('文件传输WebSocket已连接')
refreshTasks()
})
socket.value.on('tasks_list', (tasks) => {
transferTasks.value = tasks
})
socket.value.on('task_started', ({ message }) => {
$message.success(message)
refreshTasks()
})
socket.value.on('task_failed', ({ message }) => {
$message.error(message)
refreshTasks()
})
socket.value.on('task_progress', (progressData) => {
console.log(`接收到进度更新 [${ progressData.taskId }]:`, progressData)
updateTaskProgress(progressData.taskId, progressData)
})
socket.value.on('task_status_changed', ({ taskId, status, errorMessage }) => {
updateTaskStatus(taskId, status, errorMessage)
})
socket.value.on('task_cancelled', ({ message }) => {
$message.info(message)
refreshTasks()
})
socket.value.on('error', ({ message, error }) => {
$message.error(`${ message }: ${ error }`)
})
socket.value.on('token_verify_fail', () => {
$message.error('登录已过期,请重新登录')
})
socket.value.on('task_deleted', ({ message }) => {
$message.success(message)
// 任务列表会通过 tasks_list 事件自动更新
})
socket.value.on('tasks_cleared', ({ message }) => {
$message.success(message)
// 任务列表会通过 tasks_list 事件自动更新
})
}
// 服务器变化处理
const onServerChange = ({ side, server }) => {
if (side === 'left') {
leftServer.value = server
} else {
rightServer.value = server
}
}
// 传输操作
const transferToRight = () => {
performTransfer('left', 'right')
}
const transferToLeft = () => {
performTransfer('right', 'left')
}
const performTransfer = (fromSide, toSide) => {
const fromPanel = fromSide === 'left' ? leftPanelRef.value : rightPanelRef.value
const toPanel = toSide === 'left' ? leftPanelRef.value : rightPanelRef.value
const fromServer = fromSide === 'left' ? leftServer.value : rightServer.value
const toServer = toSide === 'left' ? leftServer.value : rightServer.value
const selectedFiles = fromPanel.getSelectedFiles()
if (!selectedFiles || selectedFiles.length === 0) {
$message.warning('请先选择要传输的文件或文件夹')
return
}
const targetPath = toPanel.getCurrentPath()
const transferConfig = {
sourceHostId: fromServer._id,
targetHostId: toServer._id,
sourcePaths: selectedFiles.map(file => ({
path: `${ fromPanel.getCurrentPath() }/${ file.name }`.replace(/\/+/g, '/'),
type: file.type,
size: file.size || 0
})),
targetPath,
method: 'rsync',
options: transferOptions.value,
token: token.value
}
isTransferring.value = true
socket.value.emit('start_transfer', transferConfig)
}
// 任务管理
const showTaskManager = () => {
showTaskDialog.value = true
refreshTasks()
}
const refreshTasks = () => {
if (socket.value) {
socket.value.emit('get_tasks', { token: token.value })
}
}
const cancelTask = (taskId) => {
socket.value.emit('cancel_task', { taskId, token: token.value })
}
const retryTask = (taskId) => {
socket.value.emit('retry_task', { taskId, token: token.value })
}
const clearCompletedTasks = () => {
// 发送清空请求到后端
if (socket.value) {
socket.value.emit('clear_completed_tasks', { token: token.value })
}
}
const deleteTask = (taskId) => {
// 发送删除请求到后端
if (socket.value) {
socket.value.emit('delete_task', { taskId, token: token.value })
}
}
// 更新任务状态
const updateTaskProgress = (taskId, progressData) => {
console.log(`更新任务进度 [${ taskId }]:`, progressData)
const task = transferTasks.value.find(t => t.taskId === taskId)
if (task) {
// 更新任务基本进度(向后兼容)
task.progress = progressData.overallProgress || progressData.progress || 0
task.speed = progressData.speed || 0
// 新增字段
task.completedFiles = progressData.completedFiles || 0
task.totalFiles = progressData.totalFiles || 1
task.currentFile = progressData.currentFile
task.isVerifying = progressData.isVerifying || false
console.log('任务状态已更新:', task)
} else {
console.warn(`未找到任务: ${ taskId }`)
}
}
const updateTaskStatus = (taskId, status, errorMessage) => {
const task = transferTasks.value.find(t => t.taskId === taskId)
if (task) {
task.status = status
if (errorMessage) {
task.errorMessage = errorMessage
}
}
if (status === 'completed' || status === 'failed' || status === 'cancelled') {
isTransferring.value = false
// 刷新两侧面板
setTimeout(() => {
refreshLeftPanel()
refreshRightPanel()
}, 1000)
}
}
// 面板刷新
const refreshLeftPanel = () => {
leftPanelRef.value?.refresh()
}
const refreshRightPanel = () => {
rightPanelRef.value?.refresh()
}
const handleAskAI = () => {
EventBus.$emit('sendToAIInput', '如何在不同的Linux发行版中安装sshpass与rsync?')
}
</script>
<style lang="scss" scoped>
.file_container {
.file-transfer-container {
display: flex;
flex-direction: column;
height: 100%;
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background-color: var(--el-bg-color-page);
border-bottom: 1px solid var(--el-border-color);
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
.transfer-method-info {
display: flex;
align-items: center;
gap: 8px;
.method-description {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.task-badge {
margin-left: 8px;
}
}
}
.transfer-options {
background-color: var(--el-bg-color-page);
border-bottom: 1px solid var(--el-border-color);
.options-content {
padding: 16px;
h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--el-text-color-primary);
}
.options-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
}
}
.dual-panel {
display: flex;
flex: 1;
gap: 16px;
padding: 16px;
overflow: hidden;
.panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
.panel-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-weight: 500;
color: var(--el-text-color-primary);
}
}
.transfer-controls {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 140px;
flex-shrink: 0;
.transfer-buttons {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
}
}
}
</style>

View File

@@ -13,7 +13,7 @@
<div v-else-if="connectionStatus === 'failed'" class="status_failed">
<el-icon class="error_icon"><WarningFilled /></el-icon>
<div class="error_content">
<h3>SFTP连接失败</h3>
<h3>SFTP连接断开</h3>
<p>{{ connectionError || '请检查服务端状态或网络连接' }}</p>
<el-button type="primary" size="small" @click="connectSftp">重新连接</el-button>
</div>
@@ -81,6 +81,11 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 连接控制按钮 -->
<span class="disconnect_btn" @click="toggleConnection">
<el-icon><SwitchButton /></el-icon>
</span>
</div>
<!-- 路径栏当前路径 + 操作按钮 -->
@@ -532,7 +537,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, getCurrentInstance, nextTick } from 'vue'
import { ArrowDown, ArrowLeft, Refresh, View, Hide, Edit, ArrowRight, HomeFilled, Check, Close as CloseIcon, Download, Upload, DocumentCopy, Loading, WarningFilled, Star, StarFilled, Delete } from '@element-plus/icons-vue'
import { ArrowDown, ArrowLeft, Refresh, View, Hide, Edit, ArrowRight, HomeFilled, Check, Close as CloseIcon, Download, Upload, DocumentCopy, Loading, WarningFilled, Star, StarFilled, Delete, SwitchButton } from '@element-plus/icons-vue'
import socketIo from 'socket.io-client'
import dirIcon from '@/assets/image/system/dir.png'
import linkIcon from '@/assets/image/system/link.png'
@@ -548,6 +553,10 @@ const props = defineProps({
hostId: {
type: String,
required: true
},
showCdCommand: {
type: Boolean,
default: true
}
})
@@ -1097,6 +1106,39 @@ const connectSftp = () => {
})
}
// 断开连接
const disconnectSftp = () => {
if (socket.value) {
socket.value.removeAllListeners()
socket.value.close()
socket.value = null
}
connectionStatus.value = 'failed'
connectionError.value = '手动断开连接'
loading.value = false
// 清理状态
fileListRaw.value = []
selectedRows.value = []
downloadTasks.value.clear()
uploadTasks.value.clear()
favoriteList.value = []
// 清空路径状态
previousPath.value = ''
pendingPath.value = ''
}
// 切换连接状态
const toggleConnection = () => {
if (connectionStatus.value === 'connected') {
disconnectSftp()
} else {
connectSftp()
}
}
const openDir = (path = currentPath.value, tips = true) => {
if (!socket.value) return
socket.value.emit('open_dir', path, tips)
@@ -1589,7 +1631,7 @@ const onRowContextMenu = (row, _column, event) => {
})
// 发送cd指令到终端
if (row.type === 'd') {
if (row.type === 'd' && props.showCdCommand) {
const cdCommand = `cd ${ currentPath.value }/${ row.name }`.replace(/\/+/g, '/')
items.push({
label: '发送cd指令到终端',
@@ -2161,7 +2203,7 @@ const removeUploadTask = (taskId) => {
const clearCompletedTasks = () => {
const completedTasks = Array.from(uploadTasks.value.entries())
.filter(([_, task,]) => task.status === 'completed')
.filter(([, task,]) => task.status === 'completed')
completedTasks.forEach(([taskId,]) => {
uploadTasks.value.delete(taskId)
@@ -2171,6 +2213,15 @@ const clearCompletedTasks = () => {
$message.success(`已清空 ${ completedTasks.length } 个完成任务`)
}
}
// 暴露状态和方法供父组件使用(用于文件传输功能)
defineExpose({
currentPath: computed(() => currentPath.value),
selectedRows: computed(() => selectedRows.value),
connectionStatus: computed(() => connectionStatus.value),
refresh,
openDir
})
</script>
<style lang="scss" scoped>
@@ -2185,6 +2236,14 @@ const clearCompletedTasks = () => {
gap: 8px;
padding: 5px 10px;
border-bottom: 1px solid var(--el-border-color);
.disconnect_btn {
margin-left: auto;
color: var(--el-color-warning);
cursor: pointer;
&:hover {
color: var(--el-color-danger);
}
}
}
.path_bar {