2025-06-09 14:46:36 +08:00
|
|
|
<template>
|
2025-06-12 14:15:00 +08:00
|
|
|
<div class="terminal-container">
|
|
|
|
<div class="terminal-header">
|
|
|
|
<span>SSH终端</span>
|
|
|
|
<div>
|
|
|
|
<a-button @click="connect" type="primary" size="small">连接</a-button>
|
|
|
|
<a-button @click="disconnect" size="small" style="margin-left: 8px">断开</a-button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="terminal-body" ref="terminalBody">
|
|
|
|
<div v-for="(line, index) in outputLines" :key="index" class="terminal-line">
|
|
|
|
<span v-if="line.type === 'input'" class="input-prompt">$ </span>
|
|
|
|
<span :class="line.type">{{ line.text }}</span>
|
|
|
|
</div>
|
|
|
|
<div class="terminal-input-line">
|
|
|
|
<span class="input-prompt">$ </span>
|
|
|
|
<input v-model="currentCommand" @keyup.enter="executeCommand" @keyup.up="historyUp"
|
|
|
|
@keyup.down="historyDown" ref="commandInput" class="terminal-input" />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<a-modal v-model:visible="connectionModalVisible" title="SSH连接配置" @ok="handleConnect"
|
|
|
|
:ok-button-props="{ disabled: connecting }" :cancel-button-props="{ disabled: connecting }">
|
|
|
|
<a-form :model="connectionForm" :rules="formRules" layout="vertical">
|
|
|
|
<a-form-item label="主机地址" name="host">
|
|
|
|
<a-input v-model:value="connectionForm.host" />
|
|
|
|
</a-form-item>
|
|
|
|
<a-form-item label="端口" name="port">
|
|
|
|
<a-input-number v-model:value="connectionForm.port" :min="1" :max="65535" />
|
|
|
|
</a-form-item>
|
|
|
|
<a-form-item label="用户名" name="username">
|
|
|
|
<a-input v-model:value="connectionForm.username" />
|
|
|
|
</a-form-item>
|
|
|
|
<a-form-item label="密码" name="password">
|
|
|
|
<a-input-password v-model:value="connectionForm.password" />
|
|
|
|
</a-form-item>
|
|
|
|
</a-form>
|
|
|
|
</a-modal>
|
2025-06-09 14:46:36 +08:00
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts">
|
|
|
|
import { defineComponent, ref, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
|
|
|
import { message } from 'ant-design-vue'
|
|
|
|
|
|
|
|
interface TerminalLine {
|
2025-06-12 14:15:00 +08:00
|
|
|
type: 'input' | 'output' | 'error' | 'system'
|
|
|
|
text: string
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
interface ConnectionForm {
|
2025-06-12 14:15:00 +08:00
|
|
|
host: string
|
|
|
|
port: number
|
|
|
|
username: string
|
|
|
|
password: string
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
export default defineComponent({
|
2025-06-12 14:15:00 +08:00
|
|
|
name: 'SshTerminal',
|
|
|
|
setup() {
|
|
|
|
const outputLines = ref<TerminalLine[]>([])
|
|
|
|
const currentCommand = ref('')
|
|
|
|
const commandHistory = ref<string[]>([])
|
|
|
|
const historyIndex = ref(-1)
|
|
|
|
const connectionModalVisible = ref(false)
|
|
|
|
const connecting = ref(false)
|
|
|
|
const connected = ref(false)
|
|
|
|
const socket = ref<WebSocket | null>(null)
|
|
|
|
const terminalBody = ref<HTMLElement | null>(null)
|
|
|
|
const commandInput = ref<HTMLInputElement | null>(null)
|
|
|
|
|
|
|
|
const connectionForm = ref<ConnectionForm>({
|
|
|
|
host: '121.36.88.64',
|
|
|
|
port: 22,
|
|
|
|
username: '',
|
|
|
|
password: ''
|
|
|
|
})
|
|
|
|
|
|
|
|
const formRules = {
|
|
|
|
host: [{ required: true, message: '请输入主机地址' }],
|
|
|
|
port: [{ required: true, message: '请输入端口' }],
|
|
|
|
username: [{ required: true, message: '请输入用户名' }],
|
|
|
|
password: [{ required: true, message: '请输入密码' }]
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const clientId = Math.random().toString(36).substring(2, 10)
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const addOutput = (type: TerminalLine['type'], text: string) => {
|
|
|
|
outputLines.value.push({ type, text })
|
|
|
|
scrollToBottom()
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const scrollToBottom = () => {
|
|
|
|
nextTick(() => {
|
|
|
|
if (terminalBody.value) {
|
|
|
|
terminalBody.value.scrollTop = terminalBody.value.scrollHeight
|
|
|
|
}
|
|
|
|
})
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const connect = () => {
|
|
|
|
connectionModalVisible.value = true
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const handleConnect = () => {
|
|
|
|
connecting.value = true
|
|
|
|
initWebSocket()
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const initWebSocket = () => {
|
|
|
|
const wsUrl = `ws://localhost:8080/nursing-unit/ws/ssh/${clientId}`
|
|
|
|
socket.value = new WebSocket(wsUrl)
|
|
|
|
|
|
|
|
socket.value.onopen = () => {
|
|
|
|
const { host, port, username, password } = connectionForm.value
|
|
|
|
const connectMsg = `connect:${host}:${port}:${username}:${password}`
|
|
|
|
socket.value?.send(connectMsg)
|
|
|
|
connected.value = true
|
|
|
|
connecting.value = false
|
|
|
|
connectionModalVisible.value = false
|
|
|
|
addOutput('system', 'SSH连接已建立')
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.value.onmessage = (event) => {
|
|
|
|
addOutput('output', event.data)
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.value.onerror = (error) => {
|
|
|
|
addOutput('error', '连接错误: ' + error)
|
|
|
|
connecting.value = false
|
|
|
|
connected.value = false
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.value.onclose = () => {
|
|
|
|
if (connected.value) {
|
|
|
|
addOutput('system', 'SSH连接已断开')
|
|
|
|
}
|
|
|
|
connected.value = false
|
|
|
|
connecting.value = false
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const disconnect = () => {
|
|
|
|
if (connected.value && socket.value) {
|
|
|
|
socket.value.send('disconnect')
|
|
|
|
socket.value.close()
|
|
|
|
}
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const executeCommand = () => {
|
|
|
|
if (!currentCommand.value.trim()) return
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
if (!connected.value) {
|
|
|
|
addOutput('system', '请先建立SSH连接')
|
|
|
|
return
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
// 添加到历史记录
|
|
|
|
commandHistory.value.push(currentCommand.value)
|
|
|
|
historyIndex.value = commandHistory.value.length
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
// 显示输入的命令
|
|
|
|
addOutput('input', currentCommand.value)
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
// 发送命令
|
|
|
|
socket.value?.send(currentCommand.value)
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
currentCommand.value = ''
|
|
|
|
scrollToBottom()
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const historyUp = () => {
|
|
|
|
if (commandHistory.value.length === 0) return
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
if (historyIndex.value > 0) {
|
|
|
|
historyIndex.value--
|
|
|
|
currentCommand.value = commandHistory.value[historyIndex.value]
|
|
|
|
}
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const historyDown = () => {
|
|
|
|
if (historyIndex.value < commandHistory.value.length - 1) {
|
|
|
|
historyIndex.value++
|
|
|
|
currentCommand.value = commandHistory.value[historyIndex.value]
|
|
|
|
} else if (historyIndex.value === commandHistory.value.length - 1) {
|
|
|
|
historyIndex.value++
|
|
|
|
currentCommand.value = ''
|
|
|
|
}
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const focusInput = () => {
|
|
|
|
nextTick(() => {
|
|
|
|
commandInput.value?.focus()
|
|
|
|
})
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
|
2025-06-12 14:15:00 +08:00
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
|
|
// 检测 Ctrl+C 组合键
|
|
|
|
if (event.ctrlKey && event.key === 'c') {
|
|
|
|
event.preventDefault()
|
|
|
|
if (connected.value && socket.value) {
|
|
|
|
// 发送中断信号
|
|
|
|
socket.value.send('\x03') // Ctrl+C 的 ASCII 码
|
|
|
|
addOutput('system', '^C') // 显示中断符号
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
focusInput()
|
|
|
|
// 添加键盘事件监听
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
|
|
})
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
if (socket.value) {
|
|
|
|
socket.value.close()
|
|
|
|
}
|
|
|
|
// 移除键盘事件监听
|
|
|
|
window.removeEventListener('keydown', handleKeyDown)
|
|
|
|
})
|
|
|
|
|
|
|
|
return {
|
|
|
|
outputLines,
|
|
|
|
currentCommand,
|
|
|
|
connectionModalVisible,
|
|
|
|
connecting,
|
|
|
|
connectionForm,
|
|
|
|
formRules,
|
|
|
|
terminalBody,
|
|
|
|
commandInput,
|
|
|
|
connect,
|
|
|
|
handleConnect,
|
|
|
|
disconnect,
|
|
|
|
executeCommand,
|
|
|
|
historyUp,
|
|
|
|
historyDown
|
|
|
|
}
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
})
|
|
|
|
</script>
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
.terminal-container {
|
2025-06-12 14:15:00 +08:00
|
|
|
height: 500px;
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
background-color: #121212;
|
|
|
|
color: #f0f0f0;
|
|
|
|
border-radius: 4px;
|
|
|
|
overflow: hidden;
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-header {
|
2025-06-12 14:15:00 +08:00
|
|
|
padding: 8px 12px;
|
|
|
|
background-color: #1a1a1a;
|
|
|
|
display: flex;
|
|
|
|
justify-content: space-between;
|
|
|
|
align-items: center;
|
|
|
|
border-bottom: 1px solid #2a2a2a;
|
|
|
|
font-family: 'Lucida Console', 'Consolas', monospace;
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-body {
|
2025-06-12 14:15:00 +08:00
|
|
|
flex: 1;
|
|
|
|
padding: 10px;
|
|
|
|
overflow-y: auto;
|
|
|
|
font-family: 'Lucida Console', 'Consolas', monospace;
|
|
|
|
font-size: 14px;
|
|
|
|
line-height: 1.5;
|
|
|
|
background-color: #121212;
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-line {
|
2025-06-12 14:15:00 +08:00
|
|
|
margin-bottom: 4px;
|
|
|
|
word-break: break-all;
|
|
|
|
white-space: pre-wrap;
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-input-line {
|
2025-06-12 14:15:00 +08:00
|
|
|
display: flex;
|
|
|
|
align-items: center;
|
|
|
|
background-color: rgba(255, 255, 255, 0.05);
|
|
|
|
padding: 8px;
|
|
|
|
border-radius: 2px;
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.input-prompt {
|
2025-06-12 14:15:00 +08:00
|
|
|
color: #00ff00;
|
|
|
|
margin-right: 8px;
|
|
|
|
font-weight: bold;
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-input {
|
2025-06-12 14:15:00 +08:00
|
|
|
flex: 1;
|
|
|
|
background: transparent;
|
|
|
|
border: none;
|
|
|
|
color: #ffffff;
|
|
|
|
font-family: 'Lucida Console', 'Consolas', monospace;
|
|
|
|
font-size: 14px;
|
|
|
|
outline: none;
|
|
|
|
padding: 4px 8px;
|
|
|
|
border-radius: 2px;
|
|
|
|
background-color: rgba(255, 255, 255, 0.08);
|
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-input:focus {
|
|
|
|
background-color: rgba(255, 255, 255, 0.12);
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.output {
|
2025-06-12 14:15:00 +08:00
|
|
|
color: #f0f0f0;
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.error {
|
2025-06-12 14:15:00 +08:00
|
|
|
color: #ff5555;
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.input {
|
2025-06-12 14:15:00 +08:00
|
|
|
color: #00ffff;
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
.system {
|
2025-06-12 14:15:00 +08:00
|
|
|
color: #ffff55;
|
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-body::-webkit-scrollbar {
|
|
|
|
width: 6px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-body::-webkit-scrollbar-track {
|
|
|
|
background: rgba(255, 255, 255, 0.05);
|
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-body::-webkit-scrollbar-thumb {
|
|
|
|
background: rgba(255, 255, 255, 0.15);
|
|
|
|
border-radius: 3px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.terminal-body::-webkit-scrollbar-thumb:hover {
|
|
|
|
background: rgba(255, 255, 255, 0.2);
|
2025-06-09 14:46:36 +08:00
|
|
|
}
|
|
|
|
</style>
|