feat: quick connect SFTP file manager.
This commit is contained in:
1 parent
0d83c9a8b1
commit
f9667441b1
8 files changed
+1649
-43
No files matched your search
@@ -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;
|
||||
+3
-1
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
<template>
|
||||
<div class="sftp-file-manager">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-buttons">
|
||||
<el-button size="small" @click="goToParentDirectory" :disabled="currentPath === '/'">
|
||||
<el-icon><ArrowUp /></el-icon>
|
||||
返回上级
|
||||
</el-button>
|
||||
<el-button size="small" @click="refreshDirectory">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button size="small" @click="showCreateDirDialog = true">
|
||||
<el-icon><FolderAdd /></el-icon>
|
||||
新建文件夹
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleUploadClick">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传文件
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleDeleteSelected"
|
||||
:disabled="selectedFiles.size === 0"
|
||||
type="danger"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除选中
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="path-display">
|
||||
<span>当前路径: {{ currentPath }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div class="file-list-container">
|
||||
<div v-if="loading" class="loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error">
|
||||
<el-alert :title="error" type="error" show-icon />
|
||||
</div>
|
||||
|
||||
<div v-else class="file-list">
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.path"
|
||||
class="file-item"
|
||||
:class="{
|
||||
'selected': selectedFiles.has(file.path),
|
||||
'directory': file.type === 'directory'
|
||||
}"
|
||||
@click="handleFileClick(file)"
|
||||
@dblclick="handleFileDoubleClick(file)"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<el-icon v-if="file.type === 'directory'"><Folder /></el-icon>
|
||||
<el-icon v-else-if="file.name.endsWith('.txt')"><Document /></el-icon>
|
||||
<el-icon v-else-if="file.name.endsWith('.js')"><Document /></el-icon>
|
||||
<el-icon v-else-if="file.name.endsWith('.py')"><Document /></el-icon>
|
||||
<el-icon v-else><Document /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<div class="file-details">
|
||||
<span v-if="file.type === 'file'">{{ formatFileSize(file.size) }}</span>
|
||||
<span v-else>目录</span>
|
||||
<span>{{ formatDate(file.modified) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-actions">
|
||||
<el-button
|
||||
v-if="file.type === 'file'"
|
||||
size="small"
|
||||
@click.stop="handleDownload(file)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click.stop="handleDelete(file)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="files.length === 0" class="empty-directory">
|
||||
<el-empty description="目录为空" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传输入 -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
style="display: none"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
|
||||
<!-- 对话框 -->
|
||||
<el-dialog v-model="showCreateDirDialog" title="新建文件夹" width="400px">
|
||||
<el-form :model="createDirForm" label-width="80px">
|
||||
<el-form-item label="文件夹名">
|
||||
<el-input v-model="createDirForm.name" placeholder="请输入文件夹名称" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDirDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreateDirectory">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showRenameDialog" title="重命名" width="400px">
|
||||
<el-form :model="renameForm" label-width="80px">
|
||||
<el-form-item label="新名称">
|
||||
<el-input v-model="renameForm.newName" :placeholder="renameForm.oldName" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showRenameDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleRename">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="编辑文件" width="80%" fullscreen>
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<span>正在编辑: {{ editFile.path }}</span>
|
||||
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="editFile.content"
|
||||
class="file-editor"
|
||||
placeholder="文件内容..."
|
||||
></textarea>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
ArrowUp, Refresh, FolderAdd, Upload, Delete, Loading,
|
||||
Folder, Document
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
sftp: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const fileInput = ref(null);
|
||||
|
||||
// 响应式数据
|
||||
const showCreateDirDialog = ref(false);
|
||||
const showRenameDialog = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
|
||||
const createDirForm = ref({
|
||||
name: ''
|
||||
});
|
||||
|
||||
const renameForm = ref({
|
||||
oldPath: '',
|
||||
oldName: '',
|
||||
newName: ''
|
||||
});
|
||||
|
||||
const editFile = ref({
|
||||
path: '',
|
||||
content: ''
|
||||
});
|
||||
|
||||
// 计算属性直接从 props.sftp 提取
|
||||
const currentPath = computed(() => props.sftp.currentPath.value);
|
||||
const files = computed(() => props.sftp.files.value);
|
||||
const loading = computed(() => props.sftp.loading.value);
|
||||
const error = computed(() => props.sftp.error.value);
|
||||
const selectedFiles = computed(() => props.sftp.selectedFiles.value);
|
||||
|
||||
// 方法
|
||||
const refreshDirectory = async () => {
|
||||
await props.sftp.listDirectory(currentPath.value);
|
||||
};
|
||||
|
||||
const goToParentDirectory = async () => {
|
||||
await props.sftp.goToParentDirectory();
|
||||
};
|
||||
|
||||
const handleFileClick = (file) => {
|
||||
props.sftp.toggleFileSelection(file.path);
|
||||
};
|
||||
|
||||
const handleFileDoubleClick = async (file) => {
|
||||
if (file.type === 'directory') {
|
||||
await props.sftp.enterDirectory(file.name);
|
||||
} else {
|
||||
try {
|
||||
const content = await props.sftp.readFile(file.path);
|
||||
editFile.value = {
|
||||
path: file.path,
|
||||
content: content
|
||||
};
|
||||
showEditDialog.value = true;
|
||||
} catch (err) {
|
||||
ElMessage.error('读取文件失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (file) => {
|
||||
try {
|
||||
await props.sftp.downloadFile(file.path, file.name);
|
||||
ElMessage.success('下载成功');
|
||||
} catch (err) {
|
||||
ElMessage.error('下载失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (file) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除 ${file.type === 'directory' ? '目录' : '文件'} "${file.name}" 吗?`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
);
|
||||
|
||||
await props.sftp.deleteItem(file.path, file.type);
|
||||
ElMessage.success('删除成功');
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error('删除失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
try {
|
||||
const count = selectedFiles.value.size;
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${count} 个项目吗?`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
);
|
||||
|
||||
for (const filePath of selectedFiles.value) {
|
||||
const file = files.value.find(f => f.path === filePath);
|
||||
if (file) {
|
||||
await props.sftp.deleteItem(file.path, file.type);
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success(`成功删除 ${count} 个项目`);
|
||||
props.sftp.clearSelection();
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error('删除失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleFileUpload = async (event) => {
|
||||
const filesList = Array.from(event.target.files);
|
||||
|
||||
for (const file of filesList) {
|
||||
try {
|
||||
await props.sftp.uploadFile(file);
|
||||
ElMessage.success(`文件 ${file.name} 上传成功`);
|
||||
} catch (err) {
|
||||
ElMessage.error(`文件 ${file.name} 上传失败: ` + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleCreateDirectory = async () => {
|
||||
if (!createDirForm.value.name.trim()) {
|
||||
ElMessage.warning('请输入文件夹名称');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await props.sftp.createDirectory(createDirForm.value.name.trim());
|
||||
ElMessage.success('文件夹创建成功');
|
||||
showCreateDirDialog.value = false;
|
||||
createDirForm.value.name = '';
|
||||
} catch (err) {
|
||||
ElMessage.error('创建文件夹失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renameForm.value.newName.trim()) {
|
||||
ElMessage.warning('请输入新名称');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await props.sftp.renameItem(renameForm.value.oldPath, renameForm.value.newName.trim());
|
||||
ElMessage.success('重命名成功');
|
||||
showRenameDialog.value = false;
|
||||
renameForm.value = {
|
||||
oldPath: '',
|
||||
oldName: '',
|
||||
newName: ''
|
||||
};
|
||||
} catch (err) {
|
||||
ElMessage.error('重命名失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
try {
|
||||
await props.sftp.saveFile(editFile.value.path, editFile.value.content);
|
||||
ElMessage.success('文件保存成功');
|
||||
showEditDialog.value = false;
|
||||
} catch (err) {
|
||||
ElMessage.error('保存文件失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 工具函数
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.sftp.isConnected.value) {
|
||||
props.sftp.listDirectory(props.sftp.currentPath.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sftp-file-manager {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-buttons .el-button {
|
||||
width: 120px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
margin-left: auto;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-list-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #303133;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
border-color: #e4e7ed;
|
||||
}
|
||||
|
||||
.file-item.selected {
|
||||
background-color: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.file-item.directory {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
margin-right: 10px;
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover .file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty-directory {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-editor {
|
||||
flex: 1;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-editor:focus {
|
||||
border-color: #409eff;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -153,15 +153,26 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="connecting"
|
||||
@click="handleConnect"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon><Connection /></el-icon>
|
||||
连接
|
||||
</el-button>
|
||||
<el-button-group style="width: 100%; display: flex;">
|
||||
<el-button
|
||||
type="success"
|
||||
:loading="connecting"
|
||||
@click="handleConnectSftp"
|
||||
style="flex: 1;"
|
||||
>
|
||||
<el-icon><Folder /></el-icon>
|
||||
连接 SFTP
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="connecting"
|
||||
@click="handleConnect"
|
||||
style="flex: 1;"
|
||||
>
|
||||
<el-icon><Connection /></el-icon>
|
||||
连接 SSH
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
@click="activeTabId = tab.id"
|
||||
>
|
||||
<span class="tab-dot" v-if="tab.type === 'terminal'"></span>
|
||||
<el-icon class="tab-icon-sftp" v-if="tab.type === 'sftp'"><Folder /></el-icon>
|
||||
<span class="tab-label">{{ getTabLabel(tab) }}</span>
|
||||
<el-icon class="tab-close" @click.stop="closeTab(tab.id)" v-if="tabs.length > 1"><Close /></el-icon>
|
||||
</div>
|
||||
@@ -49,6 +50,12 @@
|
||||
:connection-info="tab.connectionInfo"
|
||||
@close="closeTab(tab.id)"
|
||||
/>
|
||||
<QuickConnectSftp
|
||||
v-else-if="tab.type === 'sftp'"
|
||||
:tab-id="tab.id"
|
||||
:connection-info="tab.connectionInfo"
|
||||
@close="closeTab(tab.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,9 +64,10 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Plus, Close } from '@element-plus/icons-vue'
|
||||
import { ArrowLeft, Plus, Close, Folder } from '@element-plus/icons-vue'
|
||||
import QuickConnect from './QuickConnect.vue'
|
||||
import QuickConnectTerminal from './QuickConnectTerminal.vue'
|
||||
import QuickConnectSftp from './QuickConnectSftp.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -75,10 +83,11 @@ const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value
|
||||
const getTabLabel = (tab) => {
|
||||
if (tab.type === 'form') return '快速连接'
|
||||
const conn = tab.connectionInfo
|
||||
if (!conn) return '终端'
|
||||
if (!conn) return '会话'
|
||||
const name = conn.name || ''
|
||||
const userHost = `${conn.username || ''}@${conn.host || ''}:${conn.port || 22}`
|
||||
return name ? `${name} (${userHost})` : userHost
|
||||
const displayString = name ? `${name} (${userHost})` : userHost
|
||||
return tab.type === 'sftp' ? `[SFTP] ${displayString}` : displayString
|
||||
}
|
||||
|
||||
const createNewTab = () => {
|
||||
@@ -101,7 +110,7 @@ const closeTab = (id) => {
|
||||
const handleConnect = (tabId, connectionInfo) => {
|
||||
const tab = tabs.value.find(t => t.id === tabId)
|
||||
if (tab) {
|
||||
tab.type = 'terminal'
|
||||
tab.type = connectionInfo.mode || 'terminal'
|
||||
tab.connectionInfo = connectionInfo
|
||||
}
|
||||
}
|
||||
@@ -184,13 +193,19 @@ const handleConnect = (tabId, connectionInfo) => {
|
||||
}
|
||||
|
||||
.tab-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #67c23a;
|
||||
background-color: #52c41a;
|
||||
box-shadow: 0 0 4px rgba(82, 196, 26, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-icon-sftp {
|
||||
color: #409eff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="sftp-page">
|
||||
<el-container class="sftp-container" v-loading="quickSftp.loading.value" element-loading-text="正在连接 SFTP...">
|
||||
<!-- 顶部连接状态和操作栏 -->
|
||||
<el-header height="48px" class="sftp-header">
|
||||
<div class="header-left">
|
||||
<el-icon class="header-icon"><Folder /></el-icon>
|
||||
<span class="header-title">文件管理器
|
||||
<span class="server-info" v-if="connectionDisplay">({{ connectionDisplay }})</span>
|
||||
</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="quickSftp.isConnected.value ? 'success' : 'info'"
|
||||
class="status-tag"
|
||||
>
|
||||
{{ quickSftp.isConnected.value ? '已连接' : '未连接' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-button
|
||||
v-if="!quickSftp.isConnected.value"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="connectSftp"
|
||||
:loading="quickSftp.loading.value"
|
||||
>
|
||||
<el-icon><Connection /></el-icon>
|
||||
重试连接
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-else
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="disconnectSftp"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
断开连接
|
||||
</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容区:包含文件管理器组件 -->
|
||||
<el-main class="sftp-main">
|
||||
<!-- 未连接时的提示或错误信息 -->
|
||||
<div v-if="error" class="error-container">
|
||||
<el-alert
|
||||
:title="`连接失败: ${error}`"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
>
|
||||
<template #default>
|
||||
<div class="error-actions">
|
||||
<el-button size="small" type="primary" @click="connectSftp">重新连接</el-button>
|
||||
<el-button size="small" @click="goBack">返回上页</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!quickSftp.isConnected.value && !quickSftp.loading.value" class="waiting-container">
|
||||
<el-empty description="暂未连接 SFTP" :image-size="100">
|
||||
<template #default>
|
||||
<p class="empty-tip">点击右上角的"重试连接"按钮以尝试连接服务器</p>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 成功连接后显示文件管理器组件 -->
|
||||
<div v-else-if="quickSftp.isConnected.value" class="file-manager-wrapper">
|
||||
<QuickSftpFileManager :sftp="quickSftp" />
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Folder, Connection, Close } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useQuickSftp } from '@/composables/useQuickSftp'
|
||||
import QuickSftpFileManager from '@/components/QuickSftpFileManager.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tabId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
connectionInfo: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const quickSftp = useQuickSftp()
|
||||
const error = ref('')
|
||||
|
||||
// 显示格式化:用户名@主机:端口
|
||||
const connectionDisplay = computed(() => {
|
||||
const conn = props.connectionInfo
|
||||
if (conn) {
|
||||
return `${conn.username || ''}@${conn.host || ''}:${conn.port || 22}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 执行连接
|
||||
const connectSftp = async () => {
|
||||
error.value = ''
|
||||
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 连接成功')
|
||||
} else {
|
||||
error.value = quickSftp.error.value || '未知连接错误'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || '连接异常'
|
||||
ElMessage.error('SFTP 连接失败: ' + error.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
const disconnectSftp = async () => {
|
||||
try {
|
||||
await quickSftp.disconnectSftp()
|
||||
ElMessage.success('已断开 SFTP 连接')
|
||||
} catch (err) {
|
||||
console.error('断开连接发生错误:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回/关闭Tab
|
||||
const goBack = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 挂载时尝试连接
|
||||
onMounted(() => {
|
||||
if (props.connectionInfo) {
|
||||
connectSftp()
|
||||
}
|
||||
})
|
||||
|
||||
// 卸载时清理连接
|
||||
onUnmounted(() => {
|
||||
if (quickSftp.isConnected.value) {
|
||||
quickSftp.disconnectSftp()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sftp-page {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #f5f7fa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sftp-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sftp-header {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
font-weight: normal;
|
||||
color: #606266;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sftp-main {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: hidden; /* 防止外层滚动,由内层FileManager处理滚动 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error-container, .waiting-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
align-items: flex-start;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.file-manager-wrapper {
|
||||
flex: 1;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 确保内部 FileManager 组件能够充满容器 */
|
||||
:deep(.sftp-file-manager) {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -5,20 +5,29 @@
|
||||
<el-container class="main-content">
|
||||
<!-- SFTP文件管理器侧边栏 -->
|
||||
<el-aside
|
||||
v-if="showSftp && isConnected"
|
||||
v-if="showSftp"
|
||||
class="sftp-sidebar"
|
||||
:width="sftpPanelWidth + 'px'"
|
||||
>
|
||||
<div class="sftp-header">
|
||||
<h3>文件管理器</h3>
|
||||
<el-button size="small" @click="connectSftp" :loading="sftpStore.loading">
|
||||
<el-button
|
||||
size="small"
|
||||
@click="connectSftp"
|
||||
:loading="quickSftp.loading.value"
|
||||
:disabled="!isConnected"
|
||||
>
|
||||
<el-icon><Connection /></el-icon>
|
||||
连接SFTP
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<SftpFileManager v-if="sftpStore.isConnected" />
|
||||
<QuickSftpFileManager v-if="quickSftp.isConnected.value" :sftp="quickSftp" />
|
||||
|
||||
<div v-else-if="!isConnected" class="sftp-prompt">
|
||||
<el-icon size="48" color="#909399"><Monitor /></el-icon>
|
||||
<p>请先等待 SSH 连接成功</p>
|
||||
</div>
|
||||
<div v-else class="sftp-prompt">
|
||||
<el-icon size="48" color="#909399"><Folder /></el-icon>
|
||||
<p>点击"连接SFTP"按钮启用文件管理</p>
|
||||
@@ -115,6 +124,14 @@
|
||||
<el-icon><Monitor /></el-icon>
|
||||
{{ connectionDisplay }}
|
||||
</span>
|
||||
<span
|
||||
class="status-item clickable"
|
||||
@click="toggleSftpPanel"
|
||||
:class="{ active: showSftp }"
|
||||
>
|
||||
<el-icon><Folder /></el-icon>
|
||||
文件管理器
|
||||
</span>
|
||||
</div>
|
||||
</el-footer>
|
||||
</el-container>
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user