feat: Add a command library component
This commit is contained in:
1 parent
f01edf0203
commit
5a6e2e9cf8
7 files changed
+1255
-223
No files matched your search
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
prompt.txt
|
||||
dist
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user