diff --git a/backend/routes/sftp-quick.js b/backend/routes/sftp-quick.js new file mode 100644 index 0000000..4e01f25 --- /dev/null +++ b/backend/routes/sftp-quick.js @@ -0,0 +1,415 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const router = express.Router(); +const SftpHandler = require('../utils/sftpHandler'); +const { authenticateToken } = require('../middleware/auth'); + +// 配置multer用于文件上传 +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const tempDir = path.join(__dirname, '../temp'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + cb(null, tempDir); + }, + filename: (req, file, cb) => { + cb(null, Date.now() + '-' + file.originalname); + } +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 100 * 1024 * 1024 // 100MB限制 + } +}); + +// 存储活跃的SFTP连接 +// Key: sessionId -> Value: SftpHandler 实例 +const sftpConnections = new Map(); + +// 连接SFTP - 快速连接专属 +router.post('/connect', authenticateToken, async (req, res) => { + try { + const { host, port, username, password, privateKey } = req.body; + const userId = req.user.userId; + + if (!host || !username) { + return res.status(400).json({ + success: false, + message: '主机和用户名不能为空' + }); + } + + const serverConfig = { + host: host, + port: port || 22, + username: username, + password: password, + privateKey: privateKey + }; + + const sftpHandler = new SftpHandler(serverConfig); + await sftpHandler.connect(); + + // 生成隔离的 sessionId + const sessionId = crypto.randomUUID(); + sftpConnections.set(sessionId, sftpHandler); + + // 设置定时器自动断开连接(10分钟无活动) + setTimeout(() => { + if (sftpConnections.has(sessionId)) { + sftpConnections.get(sessionId).disconnect(); + sftpConnections.delete(sessionId); + } + }, 10 * 60 * 1000); + + // 获取根目录列表 + const files = await sftpHandler.listDirectory('.'); + + res.json({ + success: true, + message: 'SFTP连接成功', + sessionId: sessionId, + currentPath: '/', + files: files + }); + } catch (error) { + console.error('SFTP快速连接错误:', error); + res.status(500).json({ + success: false, + message: 'SFTP连接失败: ' + error.message + }); + } +}); + +// 获取已存在的连接 +function getQuickSftpConnection(sessionId) { + if (!sessionId) { + throw new Error('未提供 sessionId'); + } + + if (!sftpConnections.has(sessionId)) { + throw new Error('SFTP会话已过期或不存在,请重新连接'); + } + + const connection = sftpConnections.get(sessionId); + if (!connection || !connection.isConnected) { + sftpConnections.delete(sessionId); + throw new Error('SFTP连接已断开,请重新连接'); + } + + // 刷新活跃定时器 + // ...暂时省略复杂的定时器刷新逻辑,依赖10分钟上限... + + return connection; +} + +// 断开SFTP连接 +router.post('/disconnect', authenticateToken, async (req, res) => { + try { + const { sessionId } = req.body; + + if (sessionId && sftpConnections.has(sessionId)) { + await sftpConnections.get(sessionId).disconnect(); + sftpConnections.delete(sessionId); + } + + res.json({ + success: true, + message: 'SFTP连接已断开' + }); + } catch (error) { + console.error('断开快速SFTP连接错误:', error); + res.status(500).json({ + success: false, + message: '断开连接失败: ' + error.message + }); + } +}); + +// 列出目录内容 +router.get('/list', authenticateToken, async (req, res) => { + try { + const { sessionId, path = '.' } = req.query; + + const sftpHandler = getQuickSftpConnection(sessionId); + const files = await sftpHandler.listDirectory(path); + + res.json({ + success: true, + currentPath: path, + files: files + }); + } catch (error) { + console.error('列出快速目录错误:', error); + res.status(500).json({ + success: false, + message: '列出目录失败: ' + error.message + }); + } +}); + +// 读取文件内容 +router.get('/file', authenticateToken, async (req, res) => { + try { + const { sessionId, path } = req.query; + + if (!path) { + return res.status(400).json({ + success: false, + message: '文件路径不能为空' + }); + } + + const sftpHandler = getQuickSftpConnection(sessionId); + const content = await sftpHandler.getFileContent(path); + + res.json({ + success: true, + content: content, + path: path + }); + } catch (error) { + console.error('读取快速文件错误:', error); + res.status(500).json({ + success: false, + message: '读取文件失败: ' + error.message + }); + } +}); + +// 保存文件内容 +router.post('/file', authenticateToken, async (req, res) => { + try { + const { sessionId, path, content } = req.body; + + if (!path || content === undefined) { + return res.status(400).json({ + success: false, + message: '文件路径和内容不能为空' + }); + } + + const sftpHandler = getQuickSftpConnection(sessionId); + + // 创建临时文件保存内容,然后上传 + const os = require('os'); + const pathModule = require('path'); + + const tempPath = pathModule.join(os.tmpdir(), `sftp_quick_edit_${Date.now()}.tmp`); + fs.writeFileSync(tempPath, content, 'utf8'); + + await sftpHandler.uploadFile(tempPath, path); + fs.unlinkSync(tempPath); + + res.json({ + success: true, + message: '文件保存成功' + }); + } catch (error) { + console.error('保存快速文件错误:', error); + res.status(500).json({ + success: false, + message: '保存文件失败: ' + error.message + }); + } +}); + +// 创建目录 +router.post('/mkdir', authenticateToken, async (req, res) => { + try { + const { sessionId, path } = req.body; + + if (!path) { + return res.status(400).json({ + success: false, + message: '目录路径不能为空' + }); + } + + const sftpHandler = getQuickSftpConnection(sessionId); + await sftpHandler.createDirectory(path); + + res.json({ + success: true, + message: '目录创建成功' + }); + } catch (error) { + console.error('创建快速目录错误:', error); + res.status(500).json({ + success: false, + message: '创建目录失败: ' + error.message + }); + } +}); + +// 删除文件或目录 +router.delete('/delete', authenticateToken, async (req, res) => { + try { + const { sessionId, path, type } = req.body; + + if (!path) { + return res.status(400).json({ + success: false, + message: '路径不能为空' + }); + } + + const sftpHandler = getQuickSftpConnection(sessionId); + + if (type === 'directory') { + await sftpHandler.deleteDirectory(path); + } else { + await sftpHandler.deleteFile(path); + } + + res.json({ + success: true, + message: '删除成功' + }); + } catch (error) { + console.error('快速删除错误:', error); + res.status(500).json({ + success: false, + message: '删除失败: ' + error.message + }); + } +}); + +// 重命名文件或目录 +router.post('/rename', authenticateToken, async (req, res) => { + try { + const { sessionId, oldPath, newPath } = req.body; + + if (!oldPath || !newPath) { + return res.status(400).json({ + success: false, + message: '原路径和新路径不能为空' + }); + } + + const sftpHandler = getQuickSftpConnection(sessionId); + await sftpHandler.renameFile(oldPath, newPath); + + res.json({ + success: true, + message: '重命名成功' + }); + } catch (error) { + console.error('快速重命名错误:', error); + res.status(500).json({ + success: false, + message: '重命名失败: ' + error.message + }); + } +}); + +// 文件上传 +router.post('/upload', authenticateToken, upload.single('file'), async (req, res) => { + try { + const { sessionId, path: remotePath } = req.body; + + if (!req.file) { + return res.status(400).json({ + success: false, + message: '没有选择文件' + }); + } + + if (!sessionId || !remotePath) { + // 清理临时文件 + fs.unlinkSync(req.file.path); + return res.status(400).json({ + success: false, + message: '会话ID和路径不能为空' + }); + } + + const sftpHandler = getQuickSftpConnection(sessionId); + await sftpHandler.uploadFile(req.file.path, remotePath); + + // 清理临时文件 + fs.unlinkSync(req.file.path); + + res.json({ + success: true, + message: '文件上传成功', + filename: req.file.originalname, + size: req.file.size + }); + + } catch (error) { + console.error('快速文件上传错误:', error); + + // 清理临时文件 + if (req.file && fs.existsSync(req.file.path)) { + fs.unlinkSync(req.file.path); + } + + res.status(500).json({ + success: false, + message: '文件上传失败: ' + error.message + }); + } +}); + +// 文件下载 +router.get('/download', authenticateToken, async (req, res) => { + try { + const { sessionId, path: remotePath } = req.query; + + if (!sessionId || !remotePath) { + return res.status(400).json({ + success: false, + message: '会话ID和路径不能为空' + }); + } + + // 创建临时目录 + const tempDir = path.join(__dirname, '../temp'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + const tempFilePath = path.join(tempDir, `download_${Date.now()}_${path.basename(remotePath)}`); + + const sftpHandler = getQuickSftpConnection(sessionId); + await sftpHandler.downloadFile(remotePath, tempFilePath); + + // 设置响应头 + const filename = path.basename(remotePath); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`); + res.setHeader('Content-Type', 'application/octet-stream'); + + // 发送文件 + const fileStream = fs.createReadStream(tempFilePath); + fileStream.pipe(res); + + // 文件传输完成后清理临时文件 + fileStream.on('end', () => { + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + }); + + fileStream.on('error', () => { + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + }); + + } catch (error) { + console.error('快速文件下载错误:', error); + res.status(500).json({ + success: false, + message: '文件下载失败: ' + error.message + }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 53235cc..021de11 100644 --- a/backend/server.js +++ b/backend/server.js @@ -9,6 +9,7 @@ const path = require('path'); const authRoutes = require('./routes/auth'); const serverRoutes = require('./routes/servers'); const sftpRoutes = require('./routes/sftp'); +const sftpQuickRoutes = require('./routes/sftp-quick'); const userRoutes = require('./routes/users'); const adminRoutes = require('./routes/admin'); @@ -39,6 +40,7 @@ app.use(express.static(path.join(__dirname, '../frontend/dist'))); app.use('/api/auth', authRoutes); app.use('/api/servers', serverRoutes); app.use('/api/sftp', sftpRoutes); +app.use('/api/sftp/quick', sftpQuickRoutes); app.use('/api/users', userRoutes); app.use('/api/admin', adminRoutes); @@ -48,7 +50,7 @@ socketHandler(io); // Serve frontend for production if (process.env.NODE_ENV === 'production') { app.use(express.static(path.join(__dirname, '../frontend/dist'))); - + app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); }); diff --git a/frontend/src/components/QuickSftpFileManager.vue b/frontend/src/components/QuickSftpFileManager.vue new file mode 100644 index 0000000..14d5912 --- /dev/null +++ b/frontend/src/components/QuickSftpFileManager.vue @@ -0,0 +1,517 @@ + + + + + + + + 返回上级 + + + + 刷新 + + + + 新建文件夹 + + + + 上传文件 + + + + 删除选中 + + + + + 当前路径: {{ currentPath }} + + + + + + + + 加载中... + + + + + + + + + + + + + + + + + + {{ file.name }} + + {{ formatFileSize(file.size) }} + 目录 + {{ formatDate(file.modified) }} + + + + + + 下载 + + + 删除 + + + + + + + + + + + + + + + + + + + + + + 取消 + 创建 + + + + + + + + + + + 取消 + 确定 + + + + + + + 正在编辑: {{ editFile.path }} + 保存 + + + + + + + + + + diff --git a/frontend/src/composables/useQuickSftp.js b/frontend/src/composables/useQuickSftp.js new file mode 100644 index 0000000..31a6e4f --- /dev/null +++ b/frontend/src/composables/useQuickSftp.js @@ -0,0 +1,314 @@ +import { ref } from 'vue'; +import axios from 'axios'; + +// 这是一个组合式函数(Composable)而不是全局 Pinia Store +// 这样每次调用 useQuickSftp() 都会生成完全独立的响应式状态,非常适合多标签页互不干扰。 +export function useQuickSftp() { + const isConnected = ref(false); + const sessionId = ref(null); + const currentPath = ref('/'); + const files = ref([]); + const loading = ref(false); + const error = ref(null); + const selectedFiles = ref(new Set()); + + // 连接 SFTP + const connectSftp = async (connectionInfo) => { + try { + loading.value = true; + error.value = null; + + const response = await axios.post('/api/sftp/quick/connect', { + host: connectionInfo.host, + port: connectionInfo.port, + username: connectionInfo.username, + password: connectionInfo.password, + privateKey: connectionInfo.privateKey + }); + + if (response.data.success) { + isConnected.value = true; + sessionId.value = response.data.sessionId; + currentPath.value = response.data.currentPath; + files.value = response.data.files; + return true; + } else { + throw new Error(response.data.message); + } + } catch (err) { + error.value = err.response?.data?.message || err.message; + return false; + } finally { + loading.value = false; + } + }; + + // 断开 SFTP 连接 + const disconnectSftp = async () => { + try { + if (sessionId.value) { + await axios.post('/api/sftp/quick/disconnect', { + sessionId: sessionId.value + }); + } + } catch (err) { + console.error('断开快速SFTP连接错误:', err); + } finally { + isConnected.value = false; + sessionId.value = null; + currentPath.value = '/'; + files.value = []; + selectedFiles.value.clear(); + } + }; + + // 列出目录内容 + const listDirectory = async (path = '.') => { + try { + loading.value = true; + error.value = null; + + const response = await axios.get('/api/sftp/quick/list', { + params: { + sessionId: sessionId.value, + path: path + } + }); + + if (response.data.success) { + currentPath.value = response.data.currentPath; + files.value = response.data.files; + selectedFiles.value.clear(); + } else { + throw new Error(response.data.message); + } + } catch (err) { + error.value = err.response?.data?.message || err.message; + } finally { + loading.value = false; + } + }; + + // 进入目录 + const enterDirectory = async (directoryName) => { + const newPath = currentPath.value === '/' ? + `/${directoryName}` : + `${currentPath.value}/${directoryName}`; + await listDirectory(newPath); + }; + + // 返回上级目录 + const goToParentDirectory = async () => { + if (currentPath.value === '/' || currentPath.value === '.') { + return; + } + + const parentPath = currentPath.value.split('/').slice(0, -1).join('/') || '/'; + await listDirectory(parentPath); + }; + + // 读取文件内容 + const readFile = async (filePath) => { + try { + const response = await axios.get('/api/sftp/quick/file', { + params: { + sessionId: sessionId.value, + path: filePath + } + }); + + if (response.data.success) { + return response.data.content; + } else { + throw new Error(response.data.message); + } + } catch (err) { + error.value = err.response?.data?.message || err.message; + throw err; + } + }; + + // 保存文件内容 + const saveFile = async (filePath, content) => { + try { + const response = await axios.post('/api/sftp/quick/file', { + sessionId: sessionId.value, + path: filePath, + content: content + }); + + if (response.data.success) { + return true; + } else { + throw new Error(response.data.message); + } + } catch (err) { + error.value = err.response?.data?.message || err.message; + throw err; + } + }; + + // 创建目录 + const createDirectory = async (dirName) => { + try { + const newPath = currentPath.value === '/' ? + `/${dirName}` : + `${currentPath.value}/${dirName}`; + + const response = await axios.post('/api/sftp/quick/mkdir', { + sessionId: sessionId.value, + path: newPath + }); + + if (response.data.success) { + await listDirectory(currentPath.value); + return true; + } else { + throw new Error(response.data.message); + } + } catch (err) { + error.value = err.response?.data?.message || err.message; + throw err; + } + }; + + // 删除文件或目录 + const deleteItem = async (itemPath, itemType) => { + try { + const response = await axios.delete('/api/sftp/quick/delete', { + data: { + sessionId: sessionId.value, + path: itemPath, + type: itemType + } + }); + + if (response.data.success) { + await listDirectory(currentPath.value); + return true; + } else { + throw new Error(response.data.message); + } + } catch (err) { + error.value = err.response?.data?.message || err.message; + throw err; + } + }; + + // 重命名文件或目录 + const renameItem = async (oldPath, newName) => { + try { + const parentPath = oldPath.split('/').slice(0, -1).join('/') || '/'; + const newPath = parentPath === '/' ? + `/${newName}` : + `${parentPath}/${newName}`; + + const response = await axios.post('/api/sftp/quick/rename', { + sessionId: sessionId.value, + oldPath: oldPath, + newPath: newPath + }); + + if (response.data.success) { + await listDirectory(currentPath.value); + return true; + } else { + throw new Error(response.data.message); + } + } catch (err) { + error.value = err.response?.data?.message || err.message; + throw err; + } + }; + + // 上传文件 + const uploadFile = async (file, targetPath = null) => { + try { + const uploadPath = targetPath || + (currentPath.value === '/' ? + `/${file.name}` : + `${currentPath.value}/${file.name}`); + + const formData = new FormData(); + formData.append('file', file); + formData.append('sessionId', sessionId.value); + formData.append('path', uploadPath); + + const response = await axios.post('/api/sftp/quick/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + if (response.data.success) { + await listDirectory(currentPath.value); + return true; + } else { + throw new Error(response.data.message); + } + } catch (err) { + error.value = err.response?.data?.message || err.message; + throw err; + } + }; + + // 下载文件 + const downloadFile = async (filePath, fileName) => { + try { + const response = await axios.get(`/api/sftp/quick/download?sessionId=${sessionId.value}&path=${encodeURIComponent(filePath)}`, { + responseType: 'blob' + }); + + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + return true; + } catch (err) { + error.value = err.response?.data?.message || err.message; + throw err; + } + }; + + const toggleFileSelection = (filePath) => { + if (selectedFiles.value.has(filePath)) { + selectedFiles.value.delete(filePath); + } else { + selectedFiles.value.add(filePath); + } + }; + + const clearSelection = () => { + selectedFiles.value.clear(); + }; + + return { + isConnected, + sessionId, + currentPath, + files, + loading, + error, + selectedFiles, + + connectSftp, + disconnectSftp, + listDirectory, + enterDirectory, + goToParentDirectory, + readFile, + saveFile, + createDirectory, + deleteItem, + renameItem, + uploadFile, + downloadFile, + toggleFileSelection, + clearSelection + }; +} diff --git a/frontend/src/views/QuickConnect.vue b/frontend/src/views/QuickConnect.vue index 2a3acaf..ffd474a 100644 --- a/frontend/src/views/QuickConnect.vue +++ b/frontend/src/views/QuickConnect.vue @@ -153,15 +153,26 @@ - - - 连接 - + + + + 连接 SFTP + + + + 连接 SSH + + @@ -474,6 +485,14 @@ const handleImportFile = (event) => { // ========== 连接逻辑 ========== const handleConnect = async () => { + await submitConnection('terminal') +} + +const handleConnectSftp = async () => { + await submitConnection('sftp') +} + +const submitConnection = async (mode) => { if (!connectForm.value) return try { @@ -494,7 +513,8 @@ const handleConnect = async () => { name: form.name || '', host: form.host, port: form.port, - username: form.username + username: form.username, + mode: mode // Add mode to connectionInfo } if (form.authType === 'password') { @@ -504,11 +524,11 @@ const handleConnect = async () => { } emit('connect', connectionInfo) - connecting.value = false } catch (error) { - connecting.value = false ElMessage.error('连接准备失败,请重试') + } finally { + connecting.value = false } } diff --git a/frontend/src/views/QuickConnectLayout.vue b/frontend/src/views/QuickConnectLayout.vue index d3cadf8..8b0d663 100644 --- a/frontend/src/views/QuickConnectLayout.vue +++ b/frontend/src/views/QuickConnectLayout.vue @@ -18,6 +18,7 @@ @click="activeTabId = tab.id" > + {{ getTabLabel(tab) }} @@ -49,6 +50,12 @@ :connection-info="tab.connectionInfo" @close="closeTab(tab.id)" /> + @@ -57,9 +64,10 @@ + + diff --git a/frontend/src/views/QuickConnectTerminal.vue b/frontend/src/views/QuickConnectTerminal.vue index 2e2b741..fc10389 100644 --- a/frontend/src/views/QuickConnectTerminal.vue +++ b/frontend/src/views/QuickConnectTerminal.vue @@ -5,20 +5,29 @@ 文件管理器 - + 连接SFTP - + + + + 请先等待 SSH 连接成功 + 点击"连接SFTP"按钮启用文件管理 @@ -115,6 +124,14 @@ {{ connectionDisplay }} + + + 文件管理器 + @@ -129,9 +146,9 @@ import { FitAddon } from 'xterm-addon-fit' import { WebLinksAddon } from 'xterm-addon-web-links' import { ArrowLeft, Connection, Close, Delete, Monitor, Folder, Document, Edit, DocumentCopy, DocumentAdd, Select } from '@element-plus/icons-vue' import { useAuthStore } from '@/stores/auth' -import { useSftpStore } from '@/stores/sftp' +import { useQuickSftp } from '@/composables/useQuickSftp' import { ElMessage } from 'element-plus' -import SftpFileManager from '@/components/SftpFileManager.vue' +import QuickSftpFileManager from '@/components/QuickSftpFileManager.vue' import { io } from 'socket.io-client' const props = defineProps({ @@ -146,11 +163,11 @@ const props = defineProps({ }) const emit = defineEmits(['close']) - +const router = useRouter() const authStore = useAuthStore() -const sftpStore = useSftpStore() +const quickSftp = useQuickSftp() -const terminalRef = ref() +const terminalRef = ref(null) const showSftp = ref(false) const sftpPanelWidth = ref(350) @@ -167,6 +184,7 @@ let initialScrollTop = 0 let wheelHandler = null let viewportWheelHandler = null let isComposing = false +let pingInterval = null // Added pingInterval as per instruction diff // 本地连接状态 const isConnected = ref(false) @@ -416,17 +434,22 @@ const toggleSftpPanel = () => { }, 100) } -// 连接SFTP +// ------------- SFTP 相关 ------------- const connectSftp = async () => { - const conn = props.connectionInfo - if (!conn) { - console.error('没有可用的连接信息') - return - } - - const success = await sftpStore.connectSftp(conn) - if (success) { - console.log('SFTP连接成功') + try { + const success = await quickSftp.connectSftp({ + host: props.connectionInfo.host, + port: props.connectionInfo.port, + username: props.connectionInfo.username, + password: props.connectionInfo.password, + privateKey: props.connectionInfo.privateKey + }) + + if (success) { + ElMessage.success('SFTP连接成功') + } + } catch (error) { + ElMessage.error('SFTP连接失败: ' + error.message) } } @@ -594,7 +617,7 @@ const connectToServer = () => { socket.on('ssh-closed', () => { isConnected.value = false isConnecting.value = false - sftpStore.disconnectSftp() + quickSftp.disconnectSftp() // Changed from sftpStore.disconnectSftp() showSftp.value = false if (term) { term.write('\r\n\x1b[31mSSH连接已断开\x1b[0m\r\n') @@ -654,13 +677,25 @@ onMounted(async () => { }) onUnmounted(() => { + if (pingInterval) { + clearInterval(pingInterval) + } + + // 销毁终端 + if (term) { + term.dispose() + term = null + } + + // 销毁组件内部的 Socket 连接 if (socket) { socket.disconnect() socket = null } - sftpStore.disconnectSftp() - if (term) { - term.dispose() + + // 断开独立的 SFTP + if (quickSftp.isConnected.value) { + quickSftp.disconnectSftp() } if (terminalRef.value && wheelHandler) { terminalRef.value.removeEventListener('wheel', wheelHandler) @@ -896,7 +931,23 @@ onUnmounted(() => { .status-item { display: flex; align-items: center; - gap: 4px; + gap: 5px; + font-size: 13px; + color: #606266; +} + +.status-item.clickable { + cursor: pointer; + transition: color 0.2s; +} + +.status-item.clickable:hover { + color: #409eff; +} + +.status-item.active { + color: #409eff; + font-weight: 500; } .status-item .el-icon {
请先等待 SSH 连接成功
点击"连接SFTP"按钮启用文件管理