feat: Introduce SFTP file manager with quick connect, directory navigation, and integrated file editing capabilities.

This commit is contained in:
dalaolala committed 2026-03-03 10:57:19 +08:00
1 parent 9de8f0c2c8
commit 4cf4d8939f
8 files changed
+318 -65

No files matched your search

+75
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@guolao/vue-monaco-editor": "^1.6.0",
"axios": "^1.6.2",
"crypto-js": "^4.2.0",
"element-plus": "^2.3.14",
@@ -488,12 +489,41 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@guolao/vue-monaco-editor": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.6.0.tgz",
"integrity": "sha512-w2IiJ6eJGGeuIgCK6EKZOAfhHTTUB5aZwslzwGbZ5e89Hb4avx6++GkLTW8p84Sng/arFMjLPPxSBI56cFudyQ==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.6.1",
"vue-demi": "latest"
},
"peerDependencies": {
"@vue/composition-api": "^1.7.2",
"monaco-editor": ">=0.43.0",
"vue": "^2.6.14 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
@@ -527,6 +557,13 @@
"@types/lodash": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
@@ -781,6 +818,15 @@
"node": ">=0.4.0"
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1125,6 +1171,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmmirror.com/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1161,6 +1219,17 @@
"node": ">= 0.6"
}
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -1307,6 +1376,12 @@
"node": ">=0.10.0"
}
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/vite": {
"version": "4.5.14",
"resolved": "https://registry.npmmirror.com/vite/-/vite-4.5.14.tgz",
+1
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@guolao/vue-monaco-editor": "^1.6.0",
"axios": "^1.6.2",
"crypto-js": "^4.2.0",
"element-plus": "^2.3.14",
@@ -31,7 +31,18 @@
</div>
<div class="path-display">
<span>当前路径: {{ currentPath }}</span>
<span>当前路径:</span>
<el-input
v-model="inputPath"
placeholder="输入目录路径并回车"
@keyup.enter="handlePathEnter"
size="small"
style="width: 300px; margin-left: 10px;"
>
<template #append>
<el-button @click="handlePathEnter"><el-icon><Right /></el-icon></el-button>
</template>
</el-input>
</div>
</div>
@@ -76,6 +87,14 @@
</div>
<div class="file-actions">
<el-button
v-if="file.type === 'file'"
size="small"
type="primary"
@click.stop="handleEditFile(file)"
>
编辑
</el-button>
<el-button
v-if="file.type === 'file'"
size="small"
@@ -139,22 +158,29 @@
<span>正在编辑: {{ editFile.path }}</span>
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
</div>
<textarea
v-model="editFile.content"
class="file-editor"
placeholder="文件内容..."
></textarea>
<vue-monaco-editor
v-model:value="editFile.content"
theme="vs-dark"
:language="getLanguage(editFile.path)"
:options="{
automaticLayout: true,
fontSize: 14,
minimap: { enabled: false }
}"
class="file-editor-monaco"
/>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import {
ArrowUp, Refresh, FolderAdd, Upload, Delete, Loading,
Folder, Document
Folder, Document, Right
} from '@element-plus/icons-vue'
const props = defineProps({
@@ -193,6 +219,13 @@ const loading = computed(() => props.sftp.loading.value);
const error = computed(() => props.sftp.error.value);
const selectedFiles = computed(() => props.sftp.selectedFiles.value);
const inputPath = ref(currentPath.value);
// 当 currentPath 变化时,自动更新输入框中的路径
watch(currentPath, (newVal) => {
inputPath.value = newVal;
});
// 方法
const refreshDirectory = async () => {
await props.sftp.listDirectory(currentPath.value);
@@ -202,6 +235,28 @@ const goToParentDirectory = async () => {
await props.sftp.goToParentDirectory();
};
const handlePathEnter = async () => {
const target = inputPath.value.trim();
if (!target) {
ElMessage.warning('请输入有效路径');
return;
}
if (target === currentPath.value) {
await refreshDirectory(); // 路径没变时相当于刷新
return;
}
try {
// 假设 listDirectory 能够处理绝对路径并进入
await props.sftp.listDirectory(target);
} catch (err) {
ElMessage.error('无法进入该目录: ' + err.message);
// 回退到原来的路径显示
inputPath.value = currentPath.value;
}
};
const handleFileClick = (file) => {
props.sftp.toggleFileSelection(file.path);
};
@@ -210,19 +265,36 @@ 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);
}
handleEditFile(file);
}
};
const handleEditFile = async (file) => {
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 getLanguage = (path) => {
if (!path) return 'plaintext';
const ext = path.split('.').pop().toLowerCase();
const map = {
js: 'javascript', ts: 'typescript', vue: 'html', html: 'html', css: 'css',
json: 'json', py: 'python', java: 'java', c: 'c', cpp: 'cpp',
md: 'markdown', txt: 'plaintext', sh: 'shell', yaml: 'yaml', yml: 'yaml',
conf: 'ini', ini: 'ini', xml: 'xml', sql: 'sql', php: 'php', rb: 'ruby',
go: 'go', rs: 'rust', pb: 'protobuf'
};
return map[ext] || 'plaintext';
};
const handleDownload = async (file) => {
try {
await props.sftp.downloadFile(file.path, file.name);
@@ -405,6 +477,8 @@ onMounted(() => {
margin-left: auto;
color: #666;
font-size: 14px;
display: flex;
align-items: center;
}
.file-list-container {
@@ -500,18 +574,8 @@ onMounted(() => {
align-items: center;
}
.file-editor {
.file-editor-monaco {
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;
min-height: 0;
}
</style>
+49 -28
View File
@@ -77,6 +77,14 @@
</div>
<div class="file-actions">
<el-button
v-if="file.type === 'file'"
size="small"
type="primary"
@click.stop="handleEditFile(file)"
>
编辑
</el-button>
<el-button
v-if="file.type === 'file'"
size="small"
@@ -140,11 +148,17 @@
<span>正在编辑: {{ editFile.path }}</span>
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
</div>
<textarea
v-model="editFile.content"
class="file-editor"
placeholder="文件内容..."
></textarea>
<vue-monaco-editor
v-model:value="editFile.content"
theme="vs-dark"
:language="getLanguage(editFile.path)"
:options="{
automaticLayout: true,
fontSize: 14,
minimap: { enabled: false }
}"
class="file-editor-monaco"
/>
</div>
</el-dialog>
</div>
@@ -154,6 +168,7 @@
import { ref, onMounted, nextTick, computed } from 'vue';
import { useSftpStore } from '../stores/sftp';
import { ElMessage, ElMessageBox } from 'element-plus';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
const sftpStore = useSftpStore();
const fileInput = ref(null);
@@ -202,20 +217,36 @@ const handleFileDoubleClick = async (file) => {
if (file.type === 'directory') {
await sftpStore.enterDirectory(file.name);
} else {
// 打开文件编辑
try {
const content = await sftpStore.readFile(file.path);
editFile.value = {
path: file.path,
content: content
};
showEditDialog.value = true;
} catch (err) {
ElMessage.error('读取文件失败: ' + err.message);
}
handleEditFile(file);
}
};
const handleEditFile = async (file) => {
try {
const content = await sftpStore.readFile(file.path);
editFile.value = {
path: file.path,
content: content
};
showEditDialog.value = true;
} catch (err) {
ElMessage.error('读取文件失败: ' + err.message);
}
};
const getLanguage = (path) => {
if (!path) return 'plaintext';
const ext = path.split('.').pop().toLowerCase();
const map = {
js: 'javascript', ts: 'typescript', vue: 'html', html: 'html', css: 'css',
json: 'json', py: 'python', java: 'java', c: 'c', cpp: 'cpp',
md: 'markdown', txt: 'plaintext', sh: 'shell', yaml: 'yaml', yml: 'yaml',
conf: 'ini', ini: 'ini', xml: 'xml', sql: 'sql', php: 'php', rb: 'ruby',
go: 'go', rs: 'rust', pb: 'protobuf'
};
return map[ext] || 'plaintext';
};
const handleFileContextMenu = (event, file) => {
// 这里可以实现右键菜单
console.log('右键菜单:', file);
@@ -501,18 +532,8 @@ onMounted(() => {
align-items: center;
}
.file-editor {
.file-editor-monaco {
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;
min-height: 0;
}
</style>
+6 -2
View File
@@ -77,15 +77,19 @@ const router = createRouter({
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
const isQuickConnectOnly = localStorage.getItem('webssh_quick_connect_only') === 'true'
// 检查是否需要认证
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
next('/dashboard')
next(isQuickConnectOnly ? '/quick-connect' : '/dashboard')
} else if (to.meta.requiresAdmin && (!authStore.isAuthenticated || !authStore.user?.is_admin)) {
// 检查管理员权限
next('/dashboard')
next(isQuickConnectOnly ? '/quick-connect' : '/dashboard')
} else if (isQuickConnectOnly && to.path !== '/login' && to.path !== '/register' && !to.path.startsWith('/quick-connect')) {
// 如果是仅快速连接模式,且目标路由不是快速连接相关、也不是登录注册,拦截并跳转
next('/quick-connect')
} else {
next()
}
+15
View File
@@ -145,6 +145,14 @@
</el-col>
</el-row>
</div>
<div class="quick-connect-only-banner" style="margin-top: 30px; text-align: center;">
<el-button type="warning" plain size="large" @click="enableQuickConnectOnly">
<el-icon><Connection /></el-icon>
进入仅开启快速连接模式
</el-button>
<p style="color: #999; font-size: 13px; margin-top: 10px;">在此模式下,系统将隐藏分组、用户等其他功能页面,登录后直达快速连接面板。</p>
</div>
</div>
</el-main>
</el-container>
@@ -322,6 +330,13 @@ const handleUserCommand = async (command) => {
}
}
// 仅快速连接模式
const enableQuickConnectOnly = () => {
localStorage.setItem('webssh_quick_connect_only', 'true')
ElMessage.success('已进入仅开启快速连接模式')
router.push('/quick-connect')
}
onMounted(async () => {
await serversStore.fetchServers()
})
+58 -2
View File
@@ -43,11 +43,23 @@
/>
</div>
<div class="history-search" v-if="historyList.length > 0">
<el-input
v-model="searchKeyword"
placeholder="搜索名称或IP..."
clearable
prefix-icon="Search"
size="small"
/>
</div>
<div v-if="historyList.length > 0" class="history-tree">
<el-tree
ref="historyTreeRef"
:data="treeData"
:props="treeProps"
node-key="id"
:filter-node-method="filterNode"
:default-expanded-keys="expandedHistoryKeys"
highlight-current
@node-click="handleTreeNodeClick"
@@ -223,10 +235,10 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Connection, Delete, Close, Monitor, Lock, Folder, Upload, Download } from '@element-plus/icons-vue'
import { ArrowLeft, Connection, Delete, Close, Monitor, Lock, Folder, Upload, Download, Search } from '@element-plus/icons-vue'
import { useTerminalStore } from '@/stores/terminal'
import { useAuthStore } from '@/stores/auth'
import CryptoJS from 'crypto-js'
@@ -245,6 +257,9 @@ const connectionError = ref('')
const savePassword = ref(false)
const historyList = ref([])
const searchKeyword = ref('')
const historyTreeRef = ref()
const form = reactive({
name: '',
group: '',
@@ -363,6 +378,22 @@ const handleNodeCollapse = (data) => {
}
}
// ========== 搜索过滤功能 ==========
const filterNode = (value, data) => {
if (!value) return true
if (data.type === 'group') return true // 保留分组节点,依靠子节点匹配来决定是否显示分组
const searchLower = value.toLowerCase()
const nameMatch = (data.record?.name || '').toLowerCase().includes(searchLower)
const hostMatch = (data.record?.host || '').toLowerCase().includes(searchLower)
return nameMatch || hostMatch
}
watch(searchKeyword, (val) => {
historyTreeRef.value?.filter(val)
})
// ========== 历史记录管理 ==========
const loadHistory = () => {
@@ -647,6 +678,31 @@ onMounted(() => {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex; /* Ensure the children (aside and main) flow correctly */
}
/* 左侧栏样式 */
.history-aside {
background: white;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
}
.aside-header {
padding: 15px 20px;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
}
.history-search {
padding: 10px 20px;
border-bottom: 1px solid #ebeef5;
}
.aside-title {
}
/* 左侧历史树 */
+20 -3
View File
@@ -2,10 +2,14 @@
<div class="quick-connect-layout">
<!-- 固定顶部栏 -->
<div class="layout-header" :class="{ dark: activeTab && activeTab.type === 'terminal' }">
<el-button @click="$router.push('/dashboard')" size="small" text :style="{ color: activeTab && activeTab.type === 'terminal' ? '#aaa' : '#888' }">
<el-button v-if="!isQuickConnectOnly" @click="$router.push('/dashboard')" size="small" text :style="{ color: activeTab && activeTab.type === 'terminal' ? '#aaa' : '#888' }">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<el-button v-else @click="exitQuickConnectOnly" size="small" text :style="{ color: '#f56c6c' }">
<el-icon><SwitchButton /></el-icon>
退出纯净模式
</el-button>
<span class="header-divider"></span>
<!-- 动态 Tab 列表 -->
@@ -62,14 +66,27 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, Plus, Close, Folder } from '@element-plus/icons-vue'
import { ArrowLeft, Plus, Close, Folder, SwitchButton } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import QuickConnect from './QuickConnect.vue'
import QuickConnectTerminal from './QuickConnectTerminal.vue'
import QuickConnectSftp from './QuickConnectSftp.vue'
const router = useRouter()
const isQuickConnectOnly = ref(false)
onMounted(() => {
isQuickConnectOnly.value = localStorage.getItem('webssh_quick_connect_only') === 'true'
})
const exitQuickConnectOnly = () => {
localStorage.removeItem('webssh_quick_connect_only')
isQuickConnectOnly.value = false
ElMessage.success('已关闭仅快速连接模式!')
router.push('/dashboard')
}
const generateId = () => 'tab_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)