feat: Introduce SFTP file manager with quick connect, directory navigation, and integrated file editing capabilities.
This commit is contained in:
1 parent
9de8f0c2c8
commit
4cf4d8939f
8 files changed
+318
-65
No files matched your search
Generated
+75
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
/* 左侧历史树 */
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user