""" 主窗口 - 左侧:主机列表 + 操作 - 右侧:标签页(终端、文件浏览、监控、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, QTreeWidget, QTreeWidgetItem, QMenu, ) 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 .terminal_tab_widget import TerminalTabWidget 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_tree = QTreeWidget() self.host_tree.setHeaderHidden(True) self.host_tree.setIndentation(16) self.host_tree.setAnimated(True) self.host_tree.setExpandsOnDoubleClick(False) self.host_tree.itemSelectionChanged.connect(self._on_host_selected) self.host_tree.itemDoubleClicked.connect(self._on_host_double_clicked) # 右键菜单 self.host_tree.setContextMenuPolicy(Qt.CustomContextMenu) self.host_tree.customContextMenuRequested.connect(self._on_host_context_menu) lv.addWidget(self.host_tree, 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 = TerminalTabWidget(self.manager) 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_tree.clear() by_group = self.manager.list_hosts_by_group() # 按分组顺序添加 for gname in self.manager.list_groups(): hosts = by_group.get(gname, []) self._add_group_item(gname, hosts) # 未分组 ungrouped = by_group.get("", []) self._add_group_item("", ungrouped) # 默认展开所有分组 self.host_tree.expandAll() self._refresh_status_indicator() def _add_group_item(self, group_name: str, hosts: list): """添加一个分组节点 + 其下主机""" label = group_name if group_name else "未分组" icon = "📂" if group_name else "📭" group_item = QTreeWidgetItem([f"{icon} {label} ({len(hosts)})"]) group_item.setFont(0, QFont("sans-serif", 9, QFont.Bold)) group_item.setData(0, Qt.UserRole, "__group__") group_item.setData(0, Qt.UserRole + 1, group_name) self.host_tree.addTopLevelItem(group_item) for h in hosts: name = h.get("name", h.get("host", "?")) info = f"🖥 {name}\n {h.get('username','')}@{h.get('host','')}:{h.get('port',22)}" item = QTreeWidgetItem([info]) item.setData(0, Qt.UserRole, h.get("id")) group_item.addChild(item) def _on_host_selected(self): """选中主机时切换到对应主机;选中分组不做任何事""" items = self.host_tree.selectedItems() if not items: self.current_host_id = None return item = items[0] data = item.data(0, Qt.UserRole) if data == "__group__" or not data: # 选中了分组节点,不切换主机 return host_id = data self.current_host_id = host_id # 不再自动 attach:用户主动连才开标签,避免"切换主机=关闭旧 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() # 如果已连接:自动新建一个终端标签 conn = self.manager.get_connection(host_id) if conn and conn.connected: self.terminal_panel.open_terminal(host_id, conn=conn) self.tabs.setCurrentIndex(0) # 否则什么都不做(保持现有标签不被打扰) def _on_host_double_clicked(self, item: QTreeWidgetItem, _col: int): """双击主机节点 = 连接;双击分组节点 = 展开/折叠""" data = item.data(0, Qt.UserRole) if data == "__group__": item.setExpanded(not item.isExpanded()) return if data: self._do_connect() def _on_host_context_menu(self, pos): """右键菜单:分组操作 + 主机操作""" item = self.host_tree.itemAt(pos) menu = QMenu(self) # 空白处:新建分组 if item is None: menu.addAction("📁 新建分组", self._add_group_dialog) menu.exec_(self.host_tree.viewport().mapToGlobal(pos)) return data = item.data(0, Qt.UserRole) if data == "__group__": group_name = item.data(0, Qt.UserRole + 1) menu.addAction("📁 新建分组", self._add_group_dialog) if group_name: # 非未分组 menu.addAction("✏ 重命名分组", lambda: self._rename_group_dialog(group_name)) menu.addAction("🗑 删除分组", lambda: self._delete_group_dialog(group_name)) menu.exec_(self.host_tree.viewport().mapToGlobal(pos)) else: # 主机节点 host_id = data host = self.manager.get_host(host_id) if not host: return menu.addAction("🔌 连接", self._do_connect) menu.addAction("✏ 编辑", self._edit_host) menu.addAction("🗑 删除", self._delete_host) menu.addSeparator() # 移动到分组 move_menu = menu.addMenu("📦 移动到分组") current_group = host.get("group", "") for gname in self.manager.list_groups(): if gname != current_group: move_menu.addAction(f"📁 {gname}", lambda g=gname: self._move_host(host_id, g)) move_menu.addAction("📭 未分组", lambda: self._move_host(host_id, "")) move_menu.addAction("➕ 新建分组并移入...", lambda: self._new_group_and_move(host_id)) menu.exec_(self.host_tree.viewport().mapToGlobal(pos)) # ============================================================ # 分组操作 # ============================================================ def _add_group_dialog(self): name, ok = QInputDialog.getText(self, "新建分组", "分组名称:") if ok and name.strip(): if self.manager.add_group(name): self._load_hosts_to_list() self.statusBar().showMessage(f"已新建分组「{name.strip()}」", 3000) else: QMessageBox.warning(self, "提示", "分组名称为空或已存在") def _rename_group_dialog(self, old_name: str): new_name, ok = QInputDialog.getText(self, "重命名分组", "新名称:", text=old_name) if ok and new_name.strip() and new_name.strip() != old_name: if self.manager.rename_group(old_name, new_name): self._load_hosts_to_list() self.statusBar().showMessage(f"已重命名: {old_name} → {new_name.strip()}", 3000) else: QMessageBox.warning(self, "提示", "名称为空或已存在") def _delete_group_dialog(self, group_name: str): hosts = [h for h in self.manager.list_hosts() if h.get("group") == group_name] msg = f"删除分组「{group_name}」?" if hosts: msg += f"\n组内 {len(hosts)} 台主机将移到「未分组」。" if QMessageBox.question(self, "确认", msg, QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes: self.manager.remove_group(group_name) self._load_hosts_to_list() self.statusBar().showMessage(f"已删除分组「{group_name}」", 3000) def _move_host(self, host_id: str, group_name: str): self.manager.move_host_to_group(host_id, group_name) self._load_hosts_to_list() label = group_name if group_name else "未分组" self.statusBar().showMessage(f"已移动到「{label}」", 3000) def _new_group_and_move(self, host_id: str): name, ok = QInputDialog.getText(self, "新建分组并移入", "分组名称:") if ok and name.strip(): if self.manager.add_group(name): self.manager.move_host_to_group(host_id, name.strip()) self._load_hosts_to_list() self.statusBar().showMessage(f"已新建分组并移入「{name.strip()}」", 3000) else: QMessageBox.warning(self, "提示", "分组名称为空或已存在") 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) # 打开新终端标签 conn = self.manager.get_connection(host_id) if conn: self.terminal_panel.open_terminal(host_id, conn=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 # 只关闭当前激活标签的 shell,其他标签不受影响 self.terminal_panel.close_shell() 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.terminal_panel.shutdown() self.manager.close_all() except Exception: pass super().closeEvent(e)