""" 主窗口 - 左侧:主机列表 + 操作 - 右侧:标签页(终端、文件浏览、监控、AI Agent) """ import sys import time from typing import Optional from PyQt5.QtCore import Qt, QSize from PyQt5.QtGui import QFont, QIcon from PyQt5.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QLineEdit, QLabel, QSplitter, QTabWidget, QGroupBox, QFormLayout, QMessageBox, QStatusBar, QAction, QFileDialog, QInputDialog, QToolBar, QApplication, QStyle, ) from core.manager import ConnectionManager from core.ai_agent import AIAgent from .workers import ConnectWorker from .widgets import FileBrowser, MonitorPanel, AIChatPanel from .config_dialog import AIConfigDialog from .terminal_panel import TerminalPanel from .theme import get_qss, get_theme, set_theme APP_NAME = "SSHClient" APP_VERSION = "1.0.0" class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} - AI 增强 SSH 客户端") self.resize(1280, 800) self.manager = ConnectionManager() self.agent = AIAgent() self.current_host_id: Optional[str] = None self.connect_worker: Optional[ConnectWorker] = None self._build_ui() self._build_menu() self._build_statusbar() self._apply_theme() self._load_hosts_to_list() # ============================================================ # UI 构建 # ============================================================ def _build_ui(self): central = QWidget() self.setCentralWidget(central) root = QHBoxLayout(central) root.setContentsMargins(6, 6, 6, 6) root.setSpacing(6) splitter = QSplitter(Qt.Horizontal) root.addWidget(splitter) # ====== 左侧:主机面板 ====== left = QWidget() lv = QVBoxLayout(left) lv.setContentsMargins(4, 4, 4, 4) lv.setSpacing(6) host_title = QLabel("🖥 主机") host_title.setStyleSheet("font-size: 12pt; font-weight: bold; padding: 4px;") lv.addWidget(host_title) self.host_list = QListWidget() self.host_list.itemSelectionChanged.connect(self._on_host_selected) self.host_list.itemDoubleClicked.connect(lambda _: self._do_connect()) lv.addWidget(self.host_list, 1) # 主机操作按钮 btn_grid = QVBoxLayout() btn_grid.setSpacing(4) self.btn_add = QPushButton("➕ 新增主机") self.btn_add.clicked.connect(self._add_host) self.btn_edit = QPushButton("✏ 编辑") self.btn_edit.clicked.connect(self._edit_host) self.btn_delete = QPushButton("🗑 删除") self.btn_delete.clicked.connect(self._delete_host) self.btn_connect = QPushButton("🔌 连接") self.btn_connect.clicked.connect(self._do_connect) self.btn_disconnect = QPushButton("⛔ 断开") self.btn_disconnect.clicked.connect(self._do_disconnect) for b in (self.btn_add, self.btn_edit, self.btn_delete, self.btn_connect, self.btn_disconnect): btn_grid.addWidget(b) lv.addLayout(btn_grid) # 连接状态 self.conn_status_label = QLabel("未选择") self.conn_status_label.setStyleSheet("color: #666; padding: 4px;") lv.addWidget(self.conn_status_label) splitter.addWidget(left) # ====== 右侧:Tab 区 ====== self.tabs = QTabWidget() self.tabs.setDocumentMode(True) # Tab1: 终端 self.terminal_panel = TerminalPanel() self.tabs.addTab(self.terminal_panel, "⌨ 终端") # Tab2: 文件浏览 self.file_browser = FileBrowser(self.manager) self.tabs.addTab(self.file_browser, "📁 文件") # Tab3: 监控 self.monitor = MonitorPanel(self.manager) self.tabs.addTab(self.monitor, "📊 监控") # Tab4: AI Agent self.ai_panel = AIChatPanel(self.manager, self.agent) self.tabs.addTab(self.ai_panel, "🤖 AI Agent") splitter.addWidget(self.tabs) splitter.setSizes([280, 1000]) def _build_menu(self): menubar = self.menuBar() # 文件 m_file = menubar.addMenu("文件(&F)") act_export = QAction("导出主机配置", self) act_export.triggered.connect(self._export_hosts) m_file.addAction(act_export) act_import = QAction("导入主机配置", self) act_import.triggered.connect(self._import_hosts) m_file.addAction(act_import) m_file.addSeparator() act_exit = QAction("退出", self) act_exit.setShortcut("Ctrl+Q") act_exit.triggered.connect(self.close) m_file.addAction(act_exit) # AI m_ai = menubar.addMenu("AI(&A)") act_ai = QAction("⚙ AI 设置...", self) act_ai.triggered.connect(self._show_ai_config) m_ai.addAction(act_ai) act_clear = QAction("清空 AI 对话", self) act_clear.triggered.connect(lambda: self.ai_panel._clear()) m_ai.addAction(act_clear) # 视图 m_view = menubar.addMenu("视图(&V)") self.act_dark = QAction("🌙 暗色主题", self, checkable=True) self.act_dark.setChecked(get_theme() == "dark") self.act_dark.triggered.connect(self._toggle_theme) m_view.addAction(self.act_dark) # 帮助 m_help = menubar.addMenu("帮助(&H)") act_about = QAction("关于", self) act_about.triggered.connect(self._about) m_help.addAction(act_about) def _build_statusbar(self): self.statusBar().showMessage(f"{APP_NAME} v{APP_VERSION} 就绪") # ============================================================ # 主机列表 # ============================================================ def _load_hosts_to_list(self): self.host_list.clear() for h in self.manager.list_hosts(): item = QListWidgetItem(f"{h.get('name', h.get('host'))}\n {h.get('username','')}@{h.get('host','')}:{h.get('port',22)}") item.setData(Qt.UserRole, h.get("id")) self.host_list.addItem(item) # 默认选中第一项 if self.host_list.count() > 0: self.host_list.setCurrentRow(0) self._refresh_status_indicator() def _on_host_selected(self): items = self.host_list.selectedItems() if not items: self.current_host_id = None self.terminal_panel.close_shell() self.terminal_panel._set_status("未选择主机", "#888") self.terminal_panel.bottom_label.setText( "提示: 连接主机后这里会出现真实的 shell 提示符,可直接键入命令" ) return host_id = items[0].data(Qt.UserRole) self.current_host_id = host_id # 切换主机时关闭旧 shell self.terminal_panel.close_shell() self.file_browser.set_host(host_id) self.monitor.set_host(host_id) self.ai_panel.set_host(host_id) self._refresh_status_indicator() # 如果已连接,立即打开新 shell conn = self.manager.get_connection(host_id) if conn and conn.connected: self.terminal_panel.attach(conn) self.tabs.setCurrentIndex(0) # 切到终端 Tab 让用户看到 else: self.terminal_panel._set_status(f"未连接: 请点击「🔌 连接」", "#c62828") def _refresh_status_indicator(self): if not self.current_host_id: self.conn_status_label.setText("未选择主机") return c = self.manager.get_connection(self.current_host_id) if c and c.connected: self.conn_status_label.setText(f"🟢 已连接: {c.username}@{c.host}:{c.port}") self.conn_status_label.setStyleSheet("color: #2e7d32; padding: 4px;") else: self.conn_status_label.setText("🔴 未连接") self.conn_status_label.setStyleSheet("color: #c62828; padding: 4px;") def terminal_input_set_enabled(self, enabled: bool): self.cmd_input.setEnabled(enabled) self.btn_run.setEnabled(enabled) # ============================================================ # 主机 CRUD # ============================================================ def _add_host(self): info = self._prompt_host_info() if info is None: return self.manager.add_host(info) self._load_hosts_to_list() self.statusBar().showMessage("已新增主机", 3000) def _edit_host(self): if not self.current_host_id: return h = self.manager.get_host(self.current_host_id) if not h: return info = self._prompt_host_info(h) if info is None: return self.manager.update_host(self.current_host_id, info) self._load_hosts_to_list() self.statusBar().showMessage("已更新主机", 3000) def _delete_host(self): if not self.current_host_id: return h = self.manager.get_host(self.current_host_id) if not h: return if QMessageBox.question( self, "确认删除", f"确定删除主机「{h.get('name', h.get('host'))}」?", QMessageBox.Yes | QMessageBox.No ) != QMessageBox.Yes: return self.manager.remove_host(self.current_host_id) self._load_hosts_to_list() self.statusBar().showMessage("已删除", 3000) def _prompt_host_info(self, current: Optional[dict] = None): from .host_dialog import HostDialog dlg = HostDialog(self, current) if dlg.exec_() == dlg.Accepted: return dlg.get_value() return None # ============================================================ # 连接 # ============================================================ def _do_connect(self): if not self.current_host_id: QMessageBox.information(self, "提示", "请先选择一台主机") return if self.connect_worker and self.connect_worker.isRunning(): return h = self.manager.get_host(self.current_host_id) self.statusBar().showMessage(f"正在连接 {h.get('host')}...") self.btn_connect.setEnabled(False) self.connect_worker = ConnectWorker(self.manager, self.current_host_id) self.connect_worker.finished_with.connect(self._on_connect_done) self.connect_worker.start() def _on_connect_done(self, host_id: str, ok: bool, msg: str): self.btn_connect.setEnabled(True) if ok: self.statusBar().showMessage(msg, 5000) # 同步到 UI self.file_browser.set_host(host_id) self.monitor.set_host(host_id) # 打开 shell conn = self.manager.get_connection(host_id) if conn: self.terminal_panel.attach(conn) self.tabs.setCurrentIndex(0) # 切到终端 Tab else: QMessageBox.critical(self, "连接失败", msg) self.statusBar().showMessage(f"连接失败: {msg}", 5000) self._refresh_status_indicator() def _do_disconnect(self): if not self.current_host_id: return self.terminal_panel.close_shell() self.terminal_panel._set_status("已断开", "#888") self.manager.disconnect(self.current_host_id) self.statusBar().showMessage("已断开", 3000) self._refresh_status_indicator() # ============================================================ # AI / 关于 # ============================================================ def _apply_theme(self): theme = get_theme() qss = get_qss(theme) QApplication.instance().setStyleSheet(qss) def _toggle_theme(self): new_theme = "dark" if self.act_dark.isChecked() else "light" set_theme(new_theme) self._apply_theme() label = "暗色" if new_theme == "dark" else "亮色" self.statusBar().showMessage(f"已切换到{label}主题", 3000) def _show_ai_config(self): dlg = AIConfigDialog(self.agent, self) if dlg.exec_(): self.statusBar().showMessage("AI 配置已更新", 3000) def _about(self): QMessageBox.about( self, "关于", f"
基于 PyQt5 + paramiko 的 Windows SSH 客户端
" "本程序使用 paramiko、PyQt5、psutil、requests 等开源库。
" ) def _export_hosts(self): import json path, _ = QFileDialog.getSaveFileName(self, "导出主机配置", "hosts.json", "JSON (*.json)") if not path: return with open(path, "w", encoding="utf-8") as f: json.dump(self.manager.list_hosts(), f, ensure_ascii=False, indent=2) self.statusBar().showMessage(f"已导出到 {path}", 5000) def _import_hosts(self): import json path, _ = QFileDialog.getOpenFileName(self, "导入主机配置", "", "JSON (*.json)") if not path: return try: with open(path, "r", encoding="utf-8") as f: hosts = json.load(f) for h in hosts: if "id" in h: del h["id"] self.manager.add_host(h) self._load_hosts_to_list() self.statusBar().showMessage(f"已导入 {len(hosts)} 台主机", 5000) except Exception as e: QMessageBox.critical(self, "导入失败", str(e)) def closeEvent(self, e): try: self.monitor._stop_worker() self.manager.close_all() except Exception: pass super().closeEvent(e)