# -*- coding: utf-8 -*-
"""
SSH 终端工具 - 单页面版(exec_command 模式)
整改优化点:
1. 子线程异步执行命令,解决UI卡死未响应
2. 命令互斥锁:同一时间仅允许一条命令运行
3. 前端拦截 cls/clear 本地清屏,解决清屏无效问题
4. 端口数字校验、完善异常捕获,避免程序崩溃
5. ini自动保存上次连接IP/端口/用户名,启动自动回填
6. 新增手动清屏按钮
7. 连接中禁止重复点击连接按钮
8. 解码逻辑容错增强
"""
import sys
import os
import configparser
import paramiko
from PyQt5 import QtCore, QtGui, QtWidgets


class SSHClient:
    """SSH 客户端封装:建立连接 + 执行命令 + 断开连接"""

    def __init__(self):
        self.ssh = None  # paramiko.SSHClient 实例
        self.connected = False  # 连接状态标志

    def connect(self, host, port, username, password, timeout=10):
        """建立 SSH 连接"""
        self.ssh = paramiko.SSHClient()
        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        self.ssh.connect(
            hostname=host,
            port=port,
            username=username,
            password=password,
            timeout=timeout
        )
        self.connected = True
        return True

    def execute(self, cmd, timeout=15):
        """执行单条命令,返回 (stdout_bytes, stderr_bytes)"""
        if not self.connected or not self.ssh:
            return None, "未连接"
        stdin, stdout, stderr = self.ssh.exec_command(cmd, timeout=timeout)
        out = stdout.read()
        err = stderr.read()
        return out, err

    def disconnect(self):
        """断开 SSH 连接"""
        if self.ssh:
            try:
                self.ssh.close()
            except Exception:
                pass
        self.connected = False

    def _decode(self, data):
        """字节数据解码:优先尝试 gbk/gb2312,失败用 utf-8 兜底"""
        if not data:
            return ""
        for enc in ['gbk', 'gb2312', 'utf-8']:
            try:
                return data.decode(enc)
            except Exception:
                continue
        return data.decode('utf-8', errors='replace')


# 命令异步执行线程
class CmdRunThread(QtCore.QThread):
    result_signal = QtCore.pyqtSignal(str, str, str)  # cmd, stdout, stderr
    error_signal = QtCore.pyqtSignal(str)

    def __init__(self, ssh_client, cmd):
        super().__init__()
        self.ssh_client = ssh_client
        self.cmd = cmd

    def run(self):
        try:
            out_bytes, err_bytes = self.ssh_client.execute(self.cmd)
            out = self.ssh_client._decode(out_bytes)
            err = self.ssh_client._decode(err_bytes)
            self.result_signal.emit(self.cmd, out, err)
        except Exception as e:
            self.error_signal.emit(str(e))


class SSHTerminalWidget(QtWidgets.QWidget):
    """SSH 终端主窗口:单页面集成登录 + 终端 + 命令输入"""

    def __init__(self):
        super().__init__()
        self.setWindowTitle("SSH 终端")
        self.resize(950, 700)
        self.client = SSHClient()  # SSH 客户端实例
        self.is_cmd_running = False  # 命令忙锁:防止并发命令
        self.is_connecting = False   # 连接中锁:防止重复点连接
        self.config_path = "ssh_terminal_config.ini"
        self.cfg = configparser.ConfigParser()

        # 深色主题样式
        self.setStyleSheet("""
            QWidget { 
                background-color: #0F111A; 
                color: #00E5FF; 
                font-family: 'Consolas', 'Courier New', monospace;
            }
            QTextEdit {
                background-color: #161925;
                color: #ADB5BD;
                border: 1px solid #232733;
                border-radius: 4px;
                padding: 8px;
                font-size: 12px;
            }
            QLineEdit {
                background-color: #1A1C2C;
                border: 1px solid #3E445E;
                color: white;
                border-radius: 4px;
                padding: 6px;
                font-size: 12px;
            }
            QPushButton {
                background-color: #3D5AFE;
                color: white;
                border-radius: 4px;
                padding: 6px 16px;
                font-weight: bold;
            }
            QPushButton:hover { background-color: #536DFE; }
            QPushButton:disabled { background-color: #555; color: #888; }
            QLabel { color: #00E5FF; font-weight: bold; }
        """)

        self._setup_ui()
        self.load_last_connect_config()

    def _setup_ui(self):
        """构建界面:登录栏 + 终端输出区 + 命令输入区"""
        layout = QtWidgets.QVBoxLayout(self)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)

        # ========== 登录区域(顶部)==========
        login_frame = QtWidgets.QFrame()
        login_frame.setStyleSheet("""
            QFrame {
                background-color: rgba(30, 34, 48, 0.6);
                border: 1px solid rgba(0, 229, 255, 0.2);
                border-radius: 8px;
            }
        """)
        login_layout = QtWidgets.QGridLayout(login_frame)
        login_layout.setHorizontalSpacing(10)
        login_layout.setVerticalSpacing(8)
        login_layout.setContentsMargins(15, 12, 15, 12)

        # 主机地址
        login_layout.addWidget(QtWidgets.QLabel("主机地址:"), 0, 0)
        self.host_input = QtWidgets.QLineEdit()
        self.host_input.setMaximumWidth(180)
        login_layout.addWidget(self.host_input, 0, 1)

        # 端口
        login_layout.addWidget(QtWidgets.QLabel("端口:"), 0, 2)
        self.port_input = QtWidgets.QLineEdit("22")
        self.port_input.setMaximumWidth(60)
        login_layout.addWidget(self.port_input, 0, 3)

        # 用户名
        login_layout.addWidget(QtWidgets.QLabel("用户名:"), 0, 4)
        self.user_input = QtWidgets.QLineEdit()
        self.user_input.setMaximumWidth(150)
        login_layout.addWidget(self.user_input, 0, 5)

        # 密码
        login_layout.addWidget(QtWidgets.QLabel("密码:"), 0, 6)
        self.pwd_input = QtWidgets.QLineEdit()
        self.pwd_input.setEchoMode(QtWidgets.QLineEdit.Password)  # 密码掩码
        self.pwd_input.setMaximumWidth(150)
        login_layout.addWidget(self.pwd_input, 0, 7)

        # 连接按钮
        self.btn_connect = QtWidgets.QPushButton("连接")
        self.btn_connect.setMinimumWidth(80)
        self.btn_connect.clicked.connect(self._do_connect)
        login_layout.addWidget(self.btn_connect, 0, 8)

        # 断开按钮
        self.btn_disconnect = QtWidgets.QPushButton("断开")
        self.btn_disconnect.setMinimumWidth(80)
        self.btn_disconnect.clicked.connect(self._disconnect)
        self.btn_disconnect.setEnabled(False)
        login_layout.addWidget(self.btn_disconnect, 0, 9)

        # 清屏按钮
        self.btn_clear = QtWidgets.QPushButton("清屏")
        self.btn_clear.setMinimumWidth(70)
        self.btn_clear.clicked.connect(lambda: self.output_display.clear())
        login_layout.addWidget(self.btn_clear, 0, 10)

        # 状态标签
        self.status_label = QtWidgets.QLabel("未连接")
        self.status_label.setStyleSheet("color: #FF5252;")
        login_layout.addWidget(self.status_label, 0, 11)

        login_layout.setColumnStretch(12, 1)  # 右侧伸缩占位
        layout.addWidget(login_frame)

        # ========== 终端输出区域(中间,自适应高度)==========
        self.output_display = QtWidgets.QTextEdit()
        self.output_display.setReadOnly(True)
        self.output_display.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)  # 不换行
        font = QtGui.QFont("Consolas", 10)
        font.setStyleHint(QtGui.QFont.Monospace)
        self.output_display.setFont(font)
        layout.addWidget(self.output_display, 1)  # stretch=1 占满剩余空间

        # ========== 命令输入区域(底部)==========
        bottom_layout = QtWidgets.QHBoxLayout()
        self.cmd_label = QtWidgets.QLabel("命令:")
        self.cmd_input = QtWidgets.QLineEdit()
        self.cmd_input.setPlaceholderText("输入命令后按 Enter 发送...")
        self.cmd_input.returnPressed.connect(self._send_command)  # Enter 发送
        self.cmd_input.setEnabled(False)  # 未连接时禁用

        self.btn_send = QtWidgets.QPushButton("发送")
        self.btn_send.clicked.connect(self._send_command)
        self.btn_send.setEnabled(False)

        bottom_layout.addWidget(self.cmd_label)
        bottom_layout.addWidget(self.cmd_input, 1)
        bottom_layout.addWidget(self.btn_send)
        layout.addLayout(bottom_layout)

    def load_last_connect_config(self):
        """读取上次连接配置,自动回填IP/端口/用户名,不保存密码"""
        if not os.path.exists(self.config_path):
            return
        try:
            self.cfg.read(self.config_path, encoding="utf-8")
            if "LastConnect" in self.cfg.sections():
                self.host_input.setText(self.cfg.get("LastConnect", "host", fallback=""))
                self.port_input.setText(self.cfg.get("LastConnect", "port", fallback="22"))
                self.user_input.setText(self.cfg.get("LastConnect", "user", fallback=""))
        except Exception as e:
            print(f"读取配置失败: {e}")

    def save_connect_config(self, host, port, user):
        """连接成功保存配置"""
        try:
            if "LastConnect" not in self.cfg.sections():
                self.cfg.add_section("LastConnect")
            self.cfg.set("LastConnect", "host", host)
            self.cfg.set("LastConnect", "port", str(port))
            self.cfg.set("LastConnect", "user", user)
            with open(self.config_path, "w", encoding="utf-8") as f:
                self.cfg.write(f)
        except Exception as e:
            print(f"保存配置失败: {e}")

    def _do_connect(self):
        """点击连接按钮:验证输入 -> 建立 SSH 连接 -> 更新界面状态"""
        # 正在连接中,禁止重复点击
        if self.is_connecting:
            self._append_output("[提示] 正在建立连接,请稍等...\n")
            return

        host = self.host_input.text().strip()
        port_str = self.port_input.text().strip()
        username = self.user_input.text().strip()
        password = self.pwd_input.text()

        # 输入非空校验
        if not host or not username or not password:
            self.status_label.setText("请填写完整信息")
            self.status_label.setStyleSheet("color: #FF5252;")
            return

        # 端口数字校验
        try:
            port = int(port_str)
            if not (1 <= port <= 65535):
                self.status_label.setText("端口范围1~65535")
                self.status_label.setStyleSheet("color: #FF5252;")
                return
        except ValueError:
            self.status_label.setText("端口必须为数字")
            self.status_label.setStyleSheet("color: #FF5252;")
            return

        # 禁用登录栏,防止重复点击
        self.is_connecting = True
        self._set_login_enabled(False)
        self.output_display.clear()
        self._append_output(f"正在连接 {host}:{port} ...\n")

        try:
            self.client.connect(host, port, username, password)
            # 连接成功,保存配置
            self.save_connect_config(host, port, username)
            self.status_label.setText("已连接 | 连接成功")
            self.status_label.setStyleSheet("color: #69F0AE;")
            self.btn_connect.setEnabled(False)
            self.btn_disconnect.setEnabled(True)
            self.cmd_input.setEnabled(True)
            self.btn_send.setEnabled(True)
            self.cmd_input.setFocus()
            self._append_output("连接成功!\n\n")
        except Exception as e:
            # 连接失败,恢复登录栏
            self.status_label.setText(f"连接失败: {str(e)}")
            self.status_label.setStyleSheet("color: #FF5252;")
            self._set_login_enabled(True)
            self._append_output(f"\n[连接失败] {str(e)}\n")
        finally:
            self.is_connecting = False

    def _send_command(self):
        """点击发送或按 Enter:执行命令并显示结果"""
        cmd = self.cmd_input.text().strip()
        if not cmd:
            return

        # 命令忙锁判断
        if self.is_cmd_running:
            self._append_output("[提示] 上一条命令尚未执行完成,请等待...\n")
            return

        # 本地拦截清屏命令,不发给SSH
        if cmd.lower() in ("cls", "clear"):
            self.output_display.clear()
            self.cmd_input.clear()
            return

        # 在终端显示 "> 命令" 提示
        self._append_output(f"> {cmd}\n")
        self.cmd_input.clear()
        self.is_cmd_running = True

        # 子线程异步执行,避免UI阻塞
        self.cmd_thread = CmdRunThread(self.client, cmd)
        self.cmd_thread.result_signal.connect(self._on_cmd_finish)
        self.cmd_thread.error_signal.connect(self._on_cmd_error)
        self.cmd_thread.finished.connect(lambda: setattr(self, "is_cmd_running", False))
        self.cmd_thread.start()

    def _on_cmd_finish(self, cmd, out, err):
        """命令正常执行完成回调"""
        if out:
            self._append_output(out)
        if err:
            self._append_output(f"[错误] {err}\n")
        if not out and not err:
            self._append_output("(命令执行完成,无输出)\n")

    def _on_cmd_error(self, err_msg):
        """命令异常回调"""
        self._append_output(f"[执行错误] {err_msg}\n")

    def _append_output(self, text):
        """追加文本到终端末尾,并强制滚动到底部(解决顶部留白问题)"""
        self.output_display.moveCursor(QtGui.QTextCursor.End)
        self.output_display.insertPlainText(text)
        scrollbar = self.output_display.verticalScrollBar()
        scrollbar.setValue(scrollbar.maximum())

    def _set_login_enabled(self, enabled):
        """设置登录区域控件启用/禁用状态"""
        self.host_input.setEnabled(enabled)
        self.port_input.setEnabled(enabled)
        self.user_input.setEnabled(enabled)
        self.pwd_input.setEnabled(enabled)
        self.btn_connect.setEnabled(enabled)

    def _disconnect(self):
        """点击断开按钮:断开连接并恢复界面"""
        self.client.disconnect()
        self.is_cmd_running = False
        self.status_label.setText("未连接")
        self.status_label.setStyleSheet("color: #FF5252;")
        self._set_login_enabled(True)
        self.btn_disconnect.setEnabled(False)
        self.cmd_input.setEnabled(False)
        self.btn_send.setEnabled(False)
        self._append_output("\n[已断开连接]\n")

    def closeEvent(self, event):
        """窗口关闭时自动断开 SSH 连接"""
        self.client.disconnect()
        event.accept()


if __name__ == "__main__":
    # 屏蔽Qt图片png警告
    os.environ["QT_LOGGING_RULES"] = "qt.image.png=false"
    app = QtWidgets.QApplication(sys.argv)
    font = QtGui.QFont("Microsoft YaHei", 9)
    app.setFont(font)
    window = SSHTerminalWidget()
    window.show()
    sys.exit(app.exec_())
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐