mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-06-08 06:56:00 +08:00
feat: 支持文件对传功能
This commit is contained in:
50
LICENSE
50
LICENSE
@@ -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.
|
||||
|
||||
@@ -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
@@ -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模块抛出的服务错误
|
||||
|
||||
989
server/app/socket/file-transfer.js
Normal file
989
server/app/socket/file-transfer.js
Normal 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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "3.3.0",
|
||||
"version": "3.4.0",
|
||||
"description": "easynode-server",
|
||||
"bin": "./bin/www",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.3.0",
|
||||
"version": "3.4.0",
|
||||
"description": "easynode-web",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -11,4 +11,8 @@
|
||||
|
||||
.el-collapse {
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
|
||||
.el-loading-mask {
|
||||
z-index: 99!important;
|
||||
}
|
||||
260
web/src/components/file-transfer/sftp-panel.vue
Normal file
260
web/src/components/file-transfer/sftp-panel.vue
Normal 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>
|
||||
358
web/src/components/file-transfer/transfer-preview.vue
Normal file
358
web/src/components/file-transfer/transfer-preview.vue
Normal 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>
|
||||
650
web/src/components/file-transfer/transfer-task-manager.vue
Normal file
650
web/src/components/file-transfer/transfer-task-manager.vue
Normal 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>
|
||||
@@ -95,6 +95,7 @@ const basicFeatures = [
|
||||
const plusFeatures = [
|
||||
'AI Chat对话组件',
|
||||
'服务器代理&跳板机功能',
|
||||
'文件对传',
|
||||
'终端单窗口模式',
|
||||
'批量修改实例配置',
|
||||
'脚本库批量导出导入',
|
||||
@@ -102,7 +103,6 @@ const plusFeatures = [
|
||||
'终端功能栏docker容器管理',
|
||||
'凭据管理支持解密带密码保护的密钥',
|
||||
'通知方式无限制',
|
||||
'本地socket断开自动重连',
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
77
web/src/components/server-selector.vue
Normal file
77
web/src/components/server-selector.vue
Normal 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>
|
||||
@@ -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">sshpass、rsync</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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user