feat: Add a command library component

This commit is contained in:
dalaolala committed 2026-03-04 14:05:16 +08:00
1 parent f01edf0203
commit 5a6e2e9cf8
7 files changed
+1255 -223

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
node_modules
prompt.txt
dist
+170
View File
@@ -0,0 +1,170 @@
[
{
"category": "系统信息",
"icon": "Monitor",
"commands": [
{
"name": "查看系统版本",
"command": "cat /etc/os-release"
},
{
"name": "查看内核版本",
"command": "uname -a"
},
{
"name": "查看 CPU 信息",
"command": "lscpu"
},
{
"name": "查看内存使用",
"command": "free -h"
},
{
"name": "动态查看资源 (Top)",
"command": "top"
},
{
"name": "动态查看资源 (htop)",
"command": "htop"
},
{
"name": "查看系统运行时长",
"command": "uptime"
}
]
},
{
"category": "磁盘与文件",
"icon": "Folder",
"commands": [
{
"name": "查看磁盘空间",
"command": "df -h"
},
{
"name": "查看当前目录大小",
"command": "du -sh *"
},
{
"name": "查找大文件 (>100M)",
"command": "find / -type f -size +100M -exec ls -lh {} \\; | awk '{ print $9 \": \" $5 }'"
},
{
"name": "清空文件内容",
"command": "> filename.log"
},
{
"name": "查看日志尾部",
"command": "tail -f filename.log"
}
]
},
{
"category": "网络查验",
"icon": "Connection",
"commands": [
{
"name": "查看监听端口",
"command": "netstat -tulnp"
},
{
"name": "查看端口占用 (ss)",
"command": "ss -tulnp"
},
{
"name": "查看 IP 地址",
"command": "ip a"
},
{
"name": "测试网络连通",
"command": "ping -c 4 8.8.8.8"
},
{
"name": "追踪路由节点",
"command": "traceroute google.com"
},
{
"name": "发起 HTTP 请求",
"command": "curl -I https://www.google.com"
}
]
},
{
"category": "进程管理",
"icon": "Cpu",
"commands": [
{
"name": "查看所有进程",
"command": "ps aux"
},
{
"name": "查找 Java 进程",
"command": "ps aux | grep java"
},
{
"name": "查找 Node 进程",
"command": "ps aux | grep node"
},
{
"name": "查找 Nginx 进程",
"command": "ps aux | grep nginx"
},
{
"name": "结束指定的进程",
"command": "kill -9 PID"
}
]
},
{
"category": "Docker 常用",
"icon": "Box",
"commands": [
{
"name": "查看所有容器",
"command": "docker ps -a"
},
{
"name": "查看正在运行",
"command": "docker ps"
},
{
"name": "查看容器日志",
"command": "docker logs -f CONTAINER_ID"
},
{
"name": "查看所有镜像",
"command": "docker images"
},
{
"name": "查看 Docker 资源统计",
"command": "docker stats"
},
{
"name": "清理悬空资源",
"command": "docker system prune -f"
}
]
},
{
"category": "安全与防火墙",
"icon": "Lock",
"commands": [
{
"name": "查看防火墙状态 (Firewalld)",
"command": "systemctl status firewalld"
},
{
"name": "查看防火墙状态 (UFW)",
"command": "ufw status"
},
{
"name": "查看登录成功的用户",
"command": "last"
},
{
"name": "查看登录失败的记录",
"command": "lastb"
}
]
}
]
+542
View File
@@ -0,0 +1,542 @@
<template>
<div>
<!-- 常用命令抽屉 -->
<el-drawer
v-model="visible"
title="常用命令库"
direction="rtl"
size="320px"
:with-header="true"
>
<div class="command-list-container">
<!-- 搜索框 -->
<div class="command-search">
<el-input
v-model="searchCommandQuery"
placeholder="搜索命令名称或内容..."
clearable
:prefix-icon="Search"
/>
</div>
<el-tabs v-model="activeCommandTab" class="command-tabs">
<!-- 内置命令 Tab -->
<el-tab-pane label="内置命令" name="built-in">
<el-collapse v-model="activeCommandCategories" class="command-collapse">
<el-collapse-item
v-for="(category, index) in filteredCommands"
:key="index"
:name="index"
v-show="category.commands.length > 0"
>
<template #title>
<el-icon style="margin-right: 8px;"><component :is="category.icon" /></el-icon>
{{ category.category }}
<span class="cmd-count">({{ category.commands.length }})</span>
</template>
<div class="command-items">
<div
v-for="(cmd, cmdIdx) in category.commands"
:key="cmdIdx"
class="command-item"
>
<div class="cmd-info">
<span class="cmd-name">{{ cmd.name }}</span>
<code class="cmd-code">{{ cmd.command }}</code>
</div>
<div class="cmd-actions">
<el-button
type="primary"
size="small"
plain
@click="injectCommand(cmd.command)"
title="立即在终端运行"
>
运行
</el-button>
<el-button
size="small"
circle
@click="copyCommand(cmd.command)"
title="复制命令"
>
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
<div v-if="filteredCommands.every(c => c.commands.length === 0)" class="no-commands">
<el-empty description="没有找到匹配的内置命令" :image-size="60" />
</div>
</el-tab-pane>
<!-- 私有片段 Tab -->
<el-tab-pane label="我的片段" name="private">
<div class="private-actions">
<el-button type="success" size="small" @click="openSnippetDialog()">
<el-icon><Plus /></el-icon> 新建片段
</el-button>
<div class="private-io-actions">
<el-button size="small" text @click="exportSnippets" title="导出代码片段">
<el-icon><Download /></el-icon>
</el-button>
<el-button size="small" text @click="triggerSnippetImport" title="导入代码片段">
<el-icon><Upload /></el-icon>
</el-button>
<input
type="file"
ref="snippetFileInput"
style="display: none"
accept=".json"
@change="importSnippets"
>
</div>
</div>
<div class="command-collapse private-collapse">
<div v-if="filteredPrivateSnippets.length === 0" class="no-commands">
<el-empty description="没有匹配的私有片段" :image-size="60" />
</div>
<div class="command-items">
<div
v-for="(cmd, index) in filteredPrivateSnippets"
:key="cmd.id"
class="command-item private-item"
>
<div class="cmd-info">
<span class="cmd-name">{{ cmd.name }}</span>
<code class="cmd-code">{{ cmd.command }}</code>
</div>
<div class="cmd-actions">
<el-button
type="primary"
size="small"
plain
@click="injectCommand(cmd.command)"
title="立即在终端运行"
>
运行
</el-button>
<el-button
size="small"
circle
@click="copyCommand(cmd.command)"
title="复制命令"
>
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-dropdown trigger="click" @command="(action) => handleSnippetAction(action, cmd)">
<el-button size="small" circle style="margin-left: 8px;">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon> 编辑
</el-dropdown-item>
<el-dropdown-item command="delete" style="color: #f56c6c;">
<el-icon><Delete /></el-icon> 删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<div class="command-tips" v-if="visible">
<el-icon><Warning /></el-icon>
点击"运行"将立即在当前终端执行该命令
</div>
</div>
</el-drawer>
<!-- 私有片段编辑弹窗 -->
<el-dialog
v-model="showSnippetDialog"
:title="editingSnippet?.id ? '编辑代码片段' : '新建代码片段'"
width="400px"
append-to-body
destroy-on-close
>
<el-form ref="snippetFormRef" :model="snippetForm" :rules="snippetRules" label-width="80px">
<el-form-item label="片段名称" prop="name">
<el-input v-model="snippetForm.name" placeholder="请输入简短的名称" />
</el-form-item>
<el-form-item label="代码内容" prop="command">
<el-input
v-model="snippetForm.command"
type="textarea"
:rows="6"
placeholder="请输入完整的 Linux 命令行或 Shell 脚本片段,支持多行"
style="font-family: monospace;"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showSnippetDialog = false">取消</el-button>
<el-button type="primary" @click="saveSnippet">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { CopyDocument, Search, Warning, Edit, Delete, Plus, Download, Upload, More, Monitor, Folder, Connection, Cpu, Box, Lock } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import commonCommandsData from '@/assets/commands.json'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'command'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 状态
const commonCommands = ref(commonCommandsData)
const activeCommandCategories = ref([0, 1, 2, 3, 4, 5]) // 默认展开所有分类
const searchCommandQuery = ref('')
const activeCommandTab = ref('built-in')
// 私有代码片段相关状态
const PRIVATE_SNIPPETS_KEY = 'webssh_private_snippets'
const privateSnippets = ref(JSON.parse(localStorage.getItem(PRIVATE_SNIPPETS_KEY) || '[]'))
const showSnippetDialog = ref(false)
const snippetFormRef = ref(null)
const editingSnippet = ref(null)
const snippetFileInput = ref(null)
const snippetForm = ref({ name: '', command: '' })
const snippetRules = {
name: [{ required: true, message: '请输入片段名称', trigger: 'blur' }],
command: [{ required: true, message: '请输入命令内容', trigger: 'blur' }]
}
// 监听私有片段变化并保存
watch(privateSnippets, (newVal) => {
localStorage.setItem(PRIVATE_SNIPPETS_KEY, JSON.stringify(newVal))
}, { deep: true })
// 过滤后的内置命令
const filteredCommands = computed(() => {
const query = searchCommandQuery.value.toLowerCase().trim()
if (!query) return commonCommands.value
return commonCommands.value.map(category => {
return {
...category,
commands: category.commands.filter(cmd =>
cmd.name.toLowerCase().includes(query) ||
cmd.command.toLowerCase().includes(query)
)
}
})
})
// 过滤后的私有片段
const filteredPrivateSnippets = computed(() => {
const query = searchCommandQuery.value.toLowerCase().trim()
if (!query) return privateSnippets.value
return privateSnippets.value.filter(cmd =>
cmd.name.toLowerCase().includes(query) ||
cmd.command.toLowerCase().includes(query)
)
})
// 注入命令(发射给父组件)
const injectCommand = (cmd) => {
emit('command', cmd)
}
// 复制命令
const copyCommand = async (cmd) => {
try {
await navigator.clipboard.writeText(cmd)
ElMessage.success('命令已复制')
} catch (err) {
ElMessage.error('复制失败,您的浏览器可能不支持该功能')
}
}
// 私有片段 API
const openSnippetDialog = (snippet = null) => {
editingSnippet.value = snippet
if (snippet) {
snippetForm.value = { ...snippet }
} else {
snippetForm.value = { name: '', command: '' }
}
showSnippetDialog.value = true
}
const saveSnippet = () => {
snippetFormRef.value.validate((valid) => {
if (valid) {
if (editingSnippet.value?.id) {
// 更新
const index = privateSnippets.value.findIndex(s => s.id === editingSnippet.value.id)
if (index > -1) {
privateSnippets.value[index] = { ...editingSnippet.value, ...snippetForm.value }
}
} else {
// 新增
privateSnippets.value.unshift({
id: Date.now(),
...snippetForm.value
})
}
showSnippetDialog.value = false
ElMessage.success('保存成功')
}
})
}
const handleSnippetAction = (action, snippet) => {
if (action === 'edit') {
openSnippetDialog(snippet)
} else if (action === 'delete') {
ElMessageBox.confirm('确定要删除这个代码片段吗?', '提示', {
type: 'warning'
}).then(() => {
privateSnippets.value = privateSnippets.value.filter(s => s.id !== snippet.id)
ElMessage.success('已删除')
}).catch(() => {})
}
}
// 私有片段导入/导出
const exportSnippets = () => {
if (privateSnippets.value.length === 0) {
return ElMessage.warning('没有可导出的数据')
}
const dataStr = JSON.stringify(privateSnippets.value, null, 2)
const blob = new Blob([dataStr], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `webssh_snippets_${new Date().getTime()}.json`
a.click()
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
}
const triggerSnippetImport = () => {
snippetFileInput.value.click()
}
const importSnippets = (e) => {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
try {
const data = JSON.parse(event.target.result)
if (Array.isArray(data)) {
const validData = data.filter(item => item.name && item.command)
if (validData.length > 0) {
const existingSet = new Set(privateSnippets.value.map(s => s.name.trim() + '|' + s.command.trim()))
const newSnippets = []
validData.forEach(item => {
const key = item.name.trim() + '|' + item.command.trim()
if (!existingSet.has(key)) {
existingSet.add(key) // 导入文件中自身也可能有重复,加进去防重复
newSnippets.push({
...item,
id: Date.now() + Math.random()
})
}
})
if (newSnippets.length > 0) {
privateSnippets.value = [...newSnippets, ...privateSnippets.value]
const skipped = validData.length - newSnippets.length
if (skipped > 0) {
ElMessage.success(`成功导入 ${newSnippets.length} 条代码片段,跳过 ${skipped} 条重复项`)
} else {
ElMessage.success(`成功导入 ${newSnippets.length} 条代码片段`)
}
} else {
ElMessage.warning('导入的片段均已存在,无需重复导入')
}
} else {
ElMessage.error('文件内容格式不正确或为空')
}
} else {
ElMessage.error('无法解析该文件格式')
}
} catch (err) {
ElMessage.error('读取文件失败,请确保是合法的 JSON 文件')
}
e.target.value = ''
}
reader.readAsText(file)
}
</script>
<style scoped>
/* 常用命令抽屉样式 */
.command-list-container {
display: flex;
flex-direction: column;
height: 100%;
}
.command-search {
padding: 0 0 16px 0;
position: sticky;
top: 0;
background-color: #fff;
z-index: 5;
}
.command-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.command-tabs .el-tabs__content) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.command-tabs .el-tab-pane) {
height: 100%;
display: flex;
flex-direction: column;
}
.command-collapse {
flex: 1;
overflow-y: auto;
border-top: none;
}
.command-collapse::-webkit-scrollbar {
width: 6px;
}
.command-collapse::-webkit-scrollbar-thumb {
background-color: #dcdfe6;
border-radius: 3px;
}
.cmd-count {
margin-left: 6px;
font-size: 12px;
color: #909399;
}
.private-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 12px 0;
border-bottom: 1px solid #ebeef5;
}
.private-io-actions {
display: flex;
gap: 4px;
}
.private-collapse {
margin-top: 12px;
}
.private-item {
border-left: 3px solid #67c23a;
}
.command-items {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0 12px 0;
}
.command-item {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
background-color: #f8f9fa;
border-radius: 6px;
border: 1px solid #e4e7ed;
transition: all 0.3s;
}
.command-item:hover {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.05);
}
.cmd-info {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.cmd-name {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.cmd-code {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #e6a23c;
background-color: #2d2d30;
padding: 6px 10px;
border-radius: 4px;
word-break: break-all;
border: 1px solid #3e3e42;
}
.cmd-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
width: 100%;
}
.command-tips {
margin-top: 16px;
padding: 12px;
background-color: #f0f9eb;
color: #67c23a;
border-radius: 4px;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.no-commands {
padding: 40px 0;
}
</style>
@@ -0,0 +1,126 @@
<template>
<el-footer class="terminal-footer" v-if="isConnected">
<div class="status-bar">
<span class="status-item">
<el-icon><Connection /></el-icon>
已连接
</span>
<span class="status-item status-item-host">
<el-icon><Monitor /></el-icon>
{{ connectionDisplay }}
</span>
<span
class="status-item clickable"
@click="$emit('toggle-sftp')"
:class="{ active: showSftp }"
>
<el-icon><Folder /></el-icon>
文件管理器
</span>
<span
class="status-item clickable"
@click="$emit('show-commands')"
title="常用 Linux 命令库"
>
<el-icon><Tickets /></el-icon>
常用命令
</span>
</div>
</el-footer>
</template>
<script setup>
import { Connection, Monitor, Folder, Tickets } from '@element-plus/icons-vue'
defineProps({
isConnected: {
type: Boolean,
default: false
},
connectionDisplay: {
type: String,
default: ''
},
showSftp: {
type: Boolean,
default: false
}
})
defineEmits(['toggle-sftp', 'show-commands'])
</script>
<style scoped>
.terminal-footer {
flex-shrink: 0;
background: linear-gradient(90deg, #1e1e1e 0%, #252526 50%, #1e1e1e 100%);
border-top: 1px solid rgba(255, 255, 255, 0.08);
height: 48px;
display: flex;
align-items: center;
padding: 0 24px;
color: #a0a0a0;
font-size: 13px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 10;
}
.status-bar {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #b0b0b0;
padding: 6px 14px;
border-radius: 6px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.status-item:first-child {
color: #67c23a;
background-color: rgba(103, 194, 58, 0.1);
border-color: rgba(103, 194, 58, 0.2);
font-weight: 600;
}
.status-item-host {
color: #ffffff !important;
font-family: 'Courier New', monospace;
background-color: rgba(0, 0, 0, 0.25) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.status-item.clickable {
cursor: pointer;
}
.status-item.clickable:hover {
background-color: rgba(64, 158, 255, 0.15);
border-color: rgba(64, 158, 255, 0.4);
color: #409eff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.status-item.active {
background-color: rgba(64, 158, 255, 0.2);
border-color: rgba(64, 158, 255, 0.5);
color: #409eff;
font-weight: 600;
box-shadow: 0 0 12px rgba(64, 158, 255, 0.15);
}
.status-item .el-icon {
font-size: 15px;
}
</style>
+224 -75
View File
@@ -83,14 +83,15 @@
</div>
<!-- 连接提示 -->
<div v-if="isConnecting" class="connection-status">
<el-alert
title="正在连接服务器..."
type="info"
show-icon
:closable="false"
center
/>
<div v-if="isConnecting" class="connection-status-overlay">
<div class="connecting-card">
<div class="spinner-container">
<div class="pulse-ring"></div>
<el-icon class="is-loading" size="32" color="#409eff"><Loading /></el-icon>
</div>
<h3 class="connecting-title">正在建立安全连接</h3>
<p class="connecting-desc">正在向目标服务器发起 SSH 连接请求...</p>
</div>
</div>
<!-- 连接状态提示 -->
@@ -114,27 +115,17 @@
</el-container>
<!-- 状态栏 -->
<el-footer class="terminal-footer" v-if="isConnected">
<div class="status-bar">
<span class="status-item">
<el-icon><Connection /></el-icon>
已连接
</span>
<span class="status-item">
<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>
<TerminalStatusBar
:is-connected="isConnected"
:connection-display="connectionDisplay"
:show-sftp="showSftp"
@toggle-sftp="toggleSftpPanel"
@show-commands="showCommands = true"
/>
</el-container>
<!-- 常用命令抽屉组件 -->
<CommandLibrary v-model="showCommands" @command="injectCommand" />
</div>
</template>
@@ -144,11 +135,14 @@ import { useRouter } from 'vue-router'
import { Terminal } from 'xterm'
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 'xterm/css/xterm.css'
import { ArrowLeft, Connection, Close, Delete, Monitor, Folder, Document, Edit, DocumentCopy, DocumentAdd, Select, Tickets, Loading } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useQuickSftp } from '@/composables/useQuickSftp'
import { ElMessage } from 'element-plus'
import QuickSftpFileManager from '@/components/QuickSftpFileManager.vue'
import CommandLibrary from '@/components/CommandLibrary.vue'
import TerminalStatusBar from '@/components/TerminalStatusBar.vue'
import { io } from 'socket.io-client'
const props = defineProps({
@@ -171,6 +165,19 @@ const terminalRef = ref(null)
const showSftp = ref(false)
const sftpPanelWidth = ref(350)
// 常用命令显示控制
const showCommands = ref(false)
// 向终端注入并执行命令
const injectCommand = (cmd) => {
if (socket && isConnected.value) {
socket.emit('ssh-input', cmd + '\n')
focusTerminal()
} else {
ElMessage.warning('终端尚未连接')
}
}
// 右键菜单相关
const contextMenu = ref({
visible: false,
@@ -713,7 +720,7 @@ onUnmounted(() => {
<style scoped>
.terminal-page {
height: 100vh;
height: 100%;
overflow: hidden;
background-color: #1e1e1e;
display: flex;
@@ -847,6 +854,73 @@ onUnmounted(() => {
text-align: center;
}
/* 现代化连接状态遮罩层 */
.connection-status-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(30, 30, 30, 0.75);
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
animation: fadeIn 0.3s ease;
}
.connecting-card {
background: linear-gradient(145deg, #2a2a2d, #222225);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4);
transform: translateY(-20px);
}
.spinner-container {
position: relative;
width: 80px;
height: 80px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 24px;
}
.pulse-ring {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px solid #409eff;
animation: pulse 1.5s cubic-bezier(0.25, 0.8, 0.25, 1) infinite;
opacity: 0;
}
@keyframes pulse {
0% { transform: scale(0.5); opacity: 0.8; }
100% { transform: scale(1.5); opacity: 0; }
}
.connecting-title {
color: #ffffff;
font-size: 18px;
font-weight: 500;
margin: 0 0 8px 0;
letter-spacing: 0.5px;
}
.connecting-desc {
color: #909399;
font-size: 13px;
margin: 0;
}
.prompt-content {
color: #909399;
}
@@ -894,8 +968,126 @@ onUnmounted(() => {
cursor: not-allowed;
}
.menu-item.disabled:hover {
background-color: transparent;
.menu-item.clickable:hover {
background-color: #f0f2f5;
color: var(--el-color-primary);
}
/* 常用命令抽屉样式 */
.command-list-container {
display: flex;
flex-direction: column;
height: 100%;
}
.command-search {
padding: 0 0 16px 0;
position: sticky;
top: 0;
background-color: #fff;
z-index: 5;
}
.command-collapse {
flex: 1;
overflow-y: auto;
border-top: none;
}
.command-collapse::-webkit-scrollbar {
width: 6px;
}
.command-collapse::-webkit-scrollbar-thumb {
background-color: #dcdfe6;
border-radius: 3px;
}
.cmd-count {
margin-left: 6px;
font-size: 12px;
color: #909399;
}
.command-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.command-tabs .el-tabs__content) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.command-tabs .el-tab-pane) {
height: 100%;
display: flex;
flex-direction: column;
}
.private-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 12px 0;
border-bottom: 1px solid #ebeef5;
}
.private-io-actions {
display: flex;
gap: 4px;
}
.private-collapse {
margin-top: 12px;
}
.private-item {
border-left: 3px solid #67c23a;
}
.command-items {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0 12px 0;
}
.command-item {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
background-color: #f8f9fa;
border-radius: 6px;
border: 1px solid #e4e7ed;
transition: all 0.3s;
}
.command-item:hover {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.05);
}
.cmd-info {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.cmd-name {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.cmd-name {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.menu-divider {
@@ -909,50 +1101,7 @@ onUnmounted(() => {
width: 16px;
}
.terminal-footer {
flex-shrink: 0;
background-color: #2d2d30;
border-top: 1px solid #3e3e42;
height: 40px;
display: flex;
align-items: center;
padding: 0 20px;
color: #909399;
font-size: 12px;
}
.status-bar {
display: flex;
align-items: center;
gap: 20px;
width: 100%;
}
.status-item {
display: flex;
align-items: center;
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 {
font-size: 14px;
}
/* XTerm.js 样式调整 */
:deep(.xterm) {
+94 -122
View File
@@ -3,71 +3,16 @@
<el-container class="terminal-container">
<!-- 顶部工具栏 -->
<el-header class="terminal-header">
<div class="header-left">
<el-button @click="$router.back()" size="small">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<div class="header-back" @click="$router.back()">
<span class="back-dot dot-red"></span>
<el-icon class="back-icon"><ArrowLeft /></el-icon>
<span class="back-text">返回</span>
</div>
<div class="header-title">
<span class="server-name">{{ currentServer?.name || '快速连接' }}</span>
<span class="server-info">{{ currentServer?.host }}:{{ currentServer?.port }}</span>
</div>
<div class="header-right">
<!-- SFTP文件管理按钮 -->
<el-button
v-if="terminalStore.isConnected"
size="small"
:type="showSftp ? 'primary' : 'default'"
@click="toggleSftpPanel"
>
<el-icon><Folder /></el-icon>
{{ showSftp ? '隐藏文件' : '文件管理' }}
</el-button>
<el-button-group>
<el-button
v-if="!terminalStore.isConnected && !terminalStore.isConnecting"
type="primary"
size="small"
@click="connectToServer"
>
<el-icon><Connection /></el-icon>
连接
</el-button>
<el-button
v-if="terminalStore.isConnecting"
type="warning"
size="small"
:loading="true"
>
连接中...
</el-button>
<el-button
v-if="terminalStore.isConnected"
type="danger"
size="small"
@click="terminalStore.disconnect"
>
<el-icon><Close /></el-icon>
断开
</el-button>
</el-button-group>
<el-button size="small" @click="clearTerminal">
<el-icon><Delete /></el-icon>
清屏
</el-button>
<el-button size="small" @click="copySelected" class="copy-button">
<el-icon><Document /></el-icon>
复制选中
</el-button>
<el-button size="small" @click="pasteFromClipboard">
<el-icon><Edit /></el-icon>
粘贴剪贴板
</el-button>
<span class="server-host" v-if="currentServer">{{ currentServer.host }}:{{ currentServer.port }}</span>
</div>
<div class="header-right-placeholder"></div>
</el-header>
<!-- 主内容区域 -->
@@ -174,23 +119,17 @@
</el-container>
<!-- 状态栏 -->
<el-footer class="terminal-footer" v-if="terminalStore.isConnected">
<div class="status-bar">
<span class="status-item">
<el-icon><Connection /></el-icon>
已连接
</span>
<span class="status-item">
<el-icon><Monitor /></el-icon>
{{ currentServer?.host }}:{{ currentServer?.port }}
</span>
<span class="status-item">
<el-icon><User /></el-icon>
{{ currentServer?.username }}
</span>
</div>
</el-footer>
<TerminalStatusBar
:is-connected="terminalStore.isConnected"
:connection-display="currentServer ? `${currentServer.host}:${currentServer.port}` : ''"
:show-sftp="showSftp"
@toggle-sftp="toggleSftpPanel"
@show-commands="showCommands = true"
/>
</el-container>
<!-- 常用命令抽屉组件 -->
<CommandLibrary v-model="showCommands" @command="injectCommand" />
</div>
</template>
@@ -200,12 +139,16 @@ import { useRoute } from 'vue-router'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { WebLinksAddon } from 'xterm-addon-web-links'
import 'xterm/css/xterm.css'
import { ArrowLeft, Connection, Close, Delete, Monitor, Folder, User, Document, Edit, DocumentCopy, DocumentAdd, Select } from '@element-plus/icons-vue'
import { useTerminalStore } from '@/stores/terminal'
import { useServersStore } from '@/stores/servers'
import { useAuthStore } from '@/stores/auth'
import { useSftpStore } from '@/stores/sftp'
import SftpFileManager from '@/components/SftpFileManager.vue'
import TerminalStatusBar from '@/components/TerminalStatusBar.vue'
import CommandLibrary from '@/components/CommandLibrary.vue'
import { ElMessage } from 'element-plus'
const route = useRoute()
const terminalStore = useTerminalStore()
@@ -217,6 +160,17 @@ const terminalRef = ref()
const currentServer = ref(null)
const showSftp = ref(false)
const sftpPanelWidth = ref(350)
const showCommands = ref(false)
// 注入命令(发送给终端)
const injectCommand = (cmd) => {
if (terminalStore.isConnected) {
terminalStore.sendInput(cmd + '\n')
if (term) term.focus()
} else {
ElMessage.warning('当前没用连通的终端')
}
}
// 右键菜单相关
const contextMenu = ref({
@@ -662,7 +616,6 @@ const resetConnectedState = () => {
term.reset()
term.focus()
// 连接提示
term.write('\x1b[32mSSH连接已建立,可以开始输入命令\x1b[0m\r\n\r\n')
term.write('\x1b[?25h')
if (typeof term.scrollToTop === 'function') {
term.scrollToTop()
@@ -702,8 +655,6 @@ watch(() => terminalStore.isConnected, (isConnected, wasConnected) => {
// 延迟写入欢迎信息,确保终端准备好
setTimeout(() => {
term.write('\x1b[32mSSH连接已建立,可以开始输入命令\x1b[0m\r\n\r\n')
// 确保光标可见,并将滚动条保持在顶部
term.write('\x1b[?25h') // 显示光标
@@ -812,35 +763,83 @@ onUnmounted(() => {
}
.terminal-header {
background-color: #2d2d30;
border-bottom: 1px solid #3e3e42;
background: linear-gradient(180deg, #3a3a3c 0%, #2d2d30 100%);
border-bottom: 1px solid #1a1a1a;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
padding: 0 16px;
color: white;
height: 38px !important;
user-select: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
position: relative;
}
.header-left {
.header-back {
display: flex;
align-items: center;
gap: 16px;
gap: 5px;
cursor: pointer;
padding: 5px 10px;
border-radius: 6px;
color: #e0e0e0;
font-size: 14px;
font-weight: 500;
transition: background 0.15s ease, color 0.15s ease;
z-index: 1;
}
.header-back:hover {
background: rgba(255,255,255,0.12);
color: #ffffff;
}
.back-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-red {
background: #FF5F57;
box-shadow: 0 0 6px rgba(255, 95, 87, 0.6);
}
.back-icon {
font-size: 16px;
}
.back-text {
font-size: 14px;
letter-spacing: 0.2px;
}
.header-title {
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.server-name {
font-weight: 600;
font-size: 16px;
font-size: 13px;
color: #e0e0e0;
letter-spacing: 0.3px;
}
.server-info {
color: #909399;
font-size: 14px;
.server-host {
font-size: 11px;
color: #888;
margin-top: 1px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
.header-right-placeholder {
width: 60px;
}
.main-content {
@@ -991,34 +990,7 @@ onUnmounted(() => {
width: 16px;
}
.terminal-footer {
flex-shrink: 0; /* 确保 footer 占据固定高度,不压缩也不覆盖终端 */
background-color: #2d2d30;
border-top: 1px solid #3e3e42;
height: 40px;
display: flex;
align-items: center;
padding: 0 20px;
color: #909399;
font-size: 12px;
}
.status-bar {
display: flex;
align-items: center;
gap: 20px;
width: 100%;
}
.status-item {
display: flex;
align-items: center;
gap: 4px;
}
.status-item .el-icon {
font-size: 14px;
}
/* XTerm.js 样式调整 */
:deep(.xterm) {
+98 -26
View File
@@ -92,13 +92,16 @@
</div>
</div>
<!-- 新标签页按钮 -->
<div
class="new-tab-button"
@click="createNewTab"
title="新标签页"
>
<el-icon><Plus /></el-icon>
<!-- 右侧常用命令按钮 -->
<div class="header-actions-right">
<div
class="command-library-btn"
@click="showCommands = true"
title="常用命令库"
>
<el-icon><Tickets /></el-icon>
<span>常用命令</span>
</div>
</div>
</div>
@@ -145,7 +148,7 @@
<!-- 连接状态提示 -->
<div
v-if="!tab.connected && !tab.connecting"
v-if="!tab.connected && !tab.connecting && !tab.error"
class="connection-prompt"
>
<div class="prompt-content">
@@ -195,6 +198,9 @@
</div>
</el-main>
</el-container>
<!-- 常用命令抽屉组件 -->
<CommandLibrary v-model="showCommands" @command="injectCommand" />
</div>
</template>
@@ -204,6 +210,7 @@ import { useRouter } from 'vue-router'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { WebLinksAddon } from 'xterm-addon-web-links'
import 'xterm/css/xterm.css'
import {
Connection,
SuccessFilled,
@@ -213,13 +220,15 @@ import {
Plus,
Monitor,
Folder,
Back
Back,
Tickets
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useTerminalStore } from '@/stores/terminal'
import { useServersStore } from '@/stores/servers'
import { useAuthStore } from '@/stores/auth'
import QuickConnect from './QuickConnect.vue'
import CommandLibrary from '@/components/CommandLibrary.vue'
import { io } from 'socket.io-client'
const authStore = useAuthStore()
@@ -232,6 +241,14 @@ const tabs = ref([])
const activeTabId = ref(null)
const contextMenu = ref({ visible: false, x: 0, y: 0, tabId: null })
// 常用命令显示控制
const showCommands = ref(false)
// 当前活动的终端页签
const activeTab = computed(() => {
return tabs.value.find(tab => tab.id === activeTabId.value)
})
// 计算属性:树型结构数据
const serverTreeData = computed(() => {
const groups = {}
@@ -265,10 +282,15 @@ const treeProps = {
label: 'label'
}
// 计算属性:当前活动页签
const activeTab = computed(() => {
return tabs.value.find(tab => tab.id === activeTabId.value)
})
// 注入命令(发射给当前活动的终端)
const injectCommand = (cmd) => {
if (activeTab.value && activeTab.value.type === 'terminal' && activeTab.value.connected && activeTab.value.socket) {
activeTab.value.socket.emit('ssh-input', cmd + '\n')
focusTerminal(activeTab.value.id)
} else {
ElMessage.warning('当前没用连通的终端')
}
}
// 获取服务器状态
const getServerStatus = (serverId) => {
@@ -488,7 +510,6 @@ const clearFromContextMenu = (tabId) => {
if (!tab || !tab.terminal) return
tab.terminal.reset()
tab.terminal.focus()
tab.terminal.write('\x1b[32mSSH连接已建立,可以开始输入命令\x1b[0m\r\n\r\n')
tab.terminal.write('\x1b[?25h')
const terminalEl = document.querySelector(`[data-tab-id="${tab.id}"]`)
const viewportEl = terminalEl?.querySelector('.xterm-viewport')
@@ -622,7 +643,6 @@ const performConnection = async (tab, serverId) => {
tab.error = null
if (tab.terminal) {
tab.terminal.clear()
tab.terminal.write('\x1b[32mSSH连接已建立,可以开始输入命令\x1b[0m\r\n\r\n')
}
})
// 终端输出
@@ -680,6 +700,13 @@ const performConnection = async (tab, serverId) => {
// 切换页签
const switchTab = (tabId) => {
activeTabId.value = tabId
const tab = tabs.value.find(t => t.id === tabId)
if (tab && tab.terminal && tab.fitAddon) {
nextTick(() => {
tab.fitAddon.fit()
tab.terminal.focus()
})
}
}
// 关闭页签
@@ -820,7 +847,6 @@ const performQuickConnection = async (tab, connectionInfo) => {
tab.connecting = false
if (tab.terminal) {
tab.terminal.clear()
tab.terminal.write('\x1b[32mSSH连接已建立,可以开始输入命令\x1b[0m\r\n\r\n')
}
unwatch()
}
@@ -1094,10 +1120,14 @@ onUnmounted(() => {
.tabs-main {
padding: 0 !important;
background-color: #1e1e1e;
overflow: hidden !important;
display: flex;
flex-direction: column;
}
.tabs-container {
height: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
@@ -1118,6 +1148,37 @@ onUnmounted(() => {
overflow-y: hidden;
}
.header-actions-right {
display: flex;
align-items: center;
padding: 0 16px;
background-color: transparent;
margin-left: auto;
z-index: 10;
}
.command-library-btn {
display: flex;
align-items: center;
gap: 6px;
color: #ffffff;
font-size: 13px;
font-weight: 500;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg, #3a86ff 0%, #2575fc 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 8px rgba(58, 134, 255, 0.25);
}
.command-library-btn:hover {
background: linear-gradient(135deg, #4b92ff 0%, #3682fc 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(58, 134, 255, 0.4);
}
.tab-item {
display: flex;
align-items: center;
@@ -1170,21 +1231,29 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: #909399;
width: 32px;
height: 28px;
margin-left: 8px;
margin-top: 6px;
color: #a0a0a0;
cursor: pointer;
transition: all 0.2s ease;
border-left: 1px solid #3e3e42;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 6px;
background-color: rgba(255, 255, 255, 0.05);
flex-shrink: 0;
}
.new-tab-button:hover {
background-color: #323233;
background-color: #3a86ff;
color: #ffffff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(58, 134, 255, 0.3);
}
.tabs-content {
flex: 1;
height: calc(100% - 40px);
min-height: 0;
position: relative;
overflow: hidden;
}
@@ -1203,15 +1272,18 @@ onUnmounted(() => {
}
.terminal-wrapper {
position: relative;
width: 100%;
height: 100%;
position: absolute;
top: 10px;
left: 10px;
right: 10px;
bottom: 20px;
}
.terminal {
width: 100%;
height: 100%;
padding: 0;
box-sizing: border-box;
cursor: text;
position: relative;
}