# -*- coding: utf-8 -*- """Top-level application window.""" from util.runtime import * from util.recent_projects import load_recent_projects, save_recent_project from models.config import Config, _empty_config_data, apply_terminal_launch_config from controllers.workers import NodeStatusCheckWorker, UpdateCheckWorker, UpdateDownloadWorker from views.common import CopyableIpLabel, apply_button_style, make_line_icon, set_button_icon from views.console import OutputConsole from views.tabs import CommandTab, ConfigTab, CreateServerTab, ServerManageTab, DefaultKvTab from views.welcome import WelcomePage class MainWindow(QMainWindow): """主窗口。支持“无项目”启动:先显示欢迎页,打开项目后加载并显示功能页。""" CONSOLE_PANEL_HEIGHT = 345 AUTO_UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000 def __init__(self, config: Optional[Config] = None): super().__init__() self.config = config if config is not None else Config(project_root=None) self._project_content_widget = None # 项目内容容器,首次打开项目时创建 self._cache_init_worker = None self._startup_update_check_started = False self._update_check_worker = None self._update_prompt_active = False self._last_prompted_update_version = "" self.init_ui() def init_ui(self): self.setWindowTitle("Server Manager v{}".format(get_current_version())) self.setMinimumSize(1000, 750) # 设置浅色控制台主题 self.setStyleSheet(""" QMainWindow { background-color: #181b22; } QWidget { background-color: #181b22; color: #e8eef7; font-size: 13px; } QGroupBox { background-color: #242932; border: 1px solid #3a4352; border-radius: 8px; margin-top: 18px; padding: 16px 12px 12px 12px; font-weight: bold; font-size: 13px; } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; left: 14px; padding: 0 10px; color: #f4f7fb; background-color: #242932; } QLabel { font-size: 13px; padding: 2px; } QLineEdit, QTextEdit, QSpinBox, QComboBox, QDateTimeEdit { background-color: #242932; border: 1px solid #3a4352; border-radius: 7px; padding: 7px 9px; color: #e8eef7; font-size: 13px; min-height: 20px; selection-background-color: #4f8cff; } QLineEdit:focus, QTextEdit:focus, QSpinBox:focus, QComboBox:focus, QDateTimeEdit:focus { border: 1px solid #4f8cff; } QLineEdit:disabled, QSpinBox:disabled, QComboBox:disabled { background-color: #20242c; color: #778398; } QPushButton { background-color: #2b313c; color: #c6d7ef; border: 1px solid #4f6b92; border-radius: 6px; padding: 8px 16px; font-size: 13px; font-weight: 600; } QPushButton:hover { background-color: #263a55; } QPushButton:pressed { background-color: #2b4d75; } QPushButton:disabled { background-color: #20242c; color: #778398; border-color: #3a4352; } QTabWidget::pane { border: none; top: 0px; } QTabBar::tab { background-color: transparent; color: #aab4c3; padding: 12px 26px; border: none; border-bottom: 2px solid transparent; font-weight: 600; font-size: 13px; min-width: 86px; margin: 0px 10px 0px 0px; } QTabBar::tab:selected { color: #4f8cff; border-bottom: 2px solid #4f8cff; } QTabBar::tab:hover:!selected { color: #4f8cff; background-color: #243247; } QTabBar::tab:pressed { background-color: #263a55; } QListWidget { background-color: #242932; border: 1px solid #3a4352; border-radius: 8px; font-size: 13px; } QListWidget::item { padding: 8px 10px; border-bottom: 1px solid #303846; } QListWidget::item:selected { background-color: #4f8cff; color: #e8eef7; } QListWidget::item:hover:!selected { background-color: #243247; } QTableWidget { background-color: #242932; alternate-background-color: #2b313c; gridline-color: #303846; border: 1px solid #3a4352; border-radius: 8px; color: #e8eef7; } QTableWidget::item { padding: 6px; } QTableWidget::item:selected { background-color: #4f8cff; color: #e8eef7; } QHeaderView::section { background-color: #20242c; color: #aab4c3; border: none; border-right: 1px solid #3a4352; border-bottom: 1px solid #3a4352; padding: 8px; font-weight: 600; } QScrollArea { border: none; background-color: transparent; } QScrollBar:vertical { background-color: #181b22; width: 11px; margin: 0; } QScrollBar::handle:vertical { background-color: #3a4352; border-radius: 5px; min-height: 30px; } QScrollBar::handle:vertical:hover { background-color: #aab4c3; } QScrollBar:horizontal { background-color: #181b22; height: 11px; margin: 0; } QScrollBar::handle:horizontal { background-color: #3a4352; border-radius: 5px; min-width: 30px; } QScrollBar::handle:horizontal:hover { background-color: #aab4c3; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical, QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { height: 0px; width: 0px; } QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical, QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: none; } QRadioButton { padding: 4px 8px; font-size: 13px; spacing: 8px; } QRadioButton::indicator { width: 18px; height: 18px; } QCheckBox { padding: 4px 8px; font-size: 13px; spacing: 8px; } QCheckBox::indicator { width: 18px; height: 18px; } QFormLayout { margin: 10px; } """) # 主布局:堆叠“欢迎页”与“项目内容” central = QWidget() self.setCentralWidget(central) main_layout = QVBoxLayout(central) main_layout.setContentsMargins(0, 0, 0, 0) self.stacked = QStackedWidget() self.welcome_page = WelcomePage() self.welcome_page.open_project_requested.connect(self._on_open_project) self.project_content_container = QWidget() self.project_content_layout = QVBoxLayout(self.project_content_container) self.project_content_layout.setContentsMargins(0, 0, 0, 0) self.stacked.addWidget(self.welcome_page) self.stacked.addWidget(self.project_content_container) main_layout.addWidget(self.stacked) # 状态栏:左侧运行状态,中间版本,右侧系统时间 sb = self.statusBar() sb.showMessage("● 系统运行正常") self._status_version_label = QLabel(f"Server Manager v{get_current_version()}") self._status_version_label.setStyleSheet("color: #aab4c3; font-size: 12px; padding: 0 18px;") sb.addPermanentWidget(self._status_version_label, 1) self._status_time_label = QLabel() self._status_time_label.setStyleSheet("color: #aab4c3; font-size: 12px; padding: 0 12px;") sb.addPermanentWidget(self._status_time_label) sb.setStyleSheet("background-color: #242932; color: #25c889; font-size: 12px; padding: 6px; border-top: 1px solid #3a4352;") self._status_timer = QTimer(self) self._status_timer.timeout.connect(self._update_status_time) self._status_timer.start(1000) self._update_status_time() self._auto_update_timer = QTimer(self) self._auto_update_timer.setInterval(self.AUTO_UPDATE_CHECK_INTERVAL_MS) self._auto_update_timer.timeout.connect(self._start_startup_update_check) # 菜单栏:保证在窗口内可见(非系统原生菜单),便于看到“文件”“帮助” menubar = self.menuBar() menubar.setNativeMenuBar(False) menubar.setStyleSheet(""" QMenuBar { background-color: #242932; color: #e8eef7; padding: 5px 12px; font-size: 13px; border-bottom: 1px solid #3a4352; } QMenuBar::item { padding: 5px 10px; border-radius: 5px; } QMenuBar::item:selected { background-color: #243247; color: #4f8cff; } QMenuBar::item:pressed { background-color: #2b4d75; color: #4f8cff; } QMenu { background-color: #242932; color: #e8eef7; border: 1px solid #3a4352; padding: 5px; } QMenu::item { padding: 7px 28px 7px 18px; border-radius: 5px; } QMenu::item:selected { background-color: #4f8cff; color: #e8eef7; } """) file_menu = menubar.addMenu("文件(&F)") open_act = file_menu.addAction("打开项目(&O)...") open_act.triggered.connect(self._on_open_project_menu) # 最近的项目:打开菜单时动态刷新,点击即可切换项目 self.recent_projects_menu = file_menu.addMenu("最近的项目(&R)") file_menu.aboutToShow.connect(self._refresh_recent_projects_menu) self.close_project_act = file_menu.addAction("关闭项目(&C)") self.close_project_act.triggered.connect(self._on_close_project) file_menu.addSeparator() exit_act = file_menu.addAction("退出(&X)") exit_act.triggered.connect(self.close) # 帮助菜单:检查更新、关于(欢迎页“帮助”按钮会弹出此菜单) self.help_menu = menubar.addMenu("帮助(&H)") check_update_act = self.help_menu.addAction("检查更新(&U)") check_update_act.triggered.connect(self._on_check_update) about_act = self.help_menu.addAction("关于(&A)") about_act.triggered.connect(self._on_about) self.welcome_page.help_requested.connect(self._on_welcome_help_requested) # 无项目时先显示欢迎页;有项目时才显示项目内容 if self.config.has_project(): self._build_project_content() self.stacked.setCurrentIndex(1) self.setWindowTitle("Server Manager v{} - {}".format(get_current_version(), self.config.tool_dir)) self.close_project_act.setEnabled(True) self._run_project_migrations() else: self.stacked.setCurrentIndex(0) self.close_project_act.setEnabled(False) def _update_status_time(self): if hasattr(self, "_status_time_label"): now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self._status_time_label.setText(f"◷ 系统时间: {now}") def showEvent(self, event): super().showEvent(event) # 首次打开后延迟检查一次,随后每 5 分钟在后台检查远端版本清单。 if not self._startup_update_check_started: self._startup_update_check_started = True self._auto_update_timer.start() QTimer.singleShot(1500, self._start_startup_update_check) def _start_startup_update_check(self): """在后台检查远端版本清单(仅当配置了 update_url 时)。""" info = get_version_info() update_url = (info.get("update_url") or "").strip() if not update_url: return if self._update_check_worker and self._update_check_worker.isRunning(): return worker = UpdateCheckWorker(update_url, self) self._update_check_worker = worker worker.finished_signal.connect(self._on_startup_update_checked) worker.finished.connect(lambda w=worker: self._clear_update_check_worker(w)) worker.finished.connect(worker.deleteLater) worker.start() def _clear_update_check_worker(self, worker): if self._update_check_worker is worker: self._update_check_worker = None def _on_startup_update_checked(self, manifest): """后台检查结果:若远端版本更高则弹窗询问是否更新。""" if not manifest: return current = get_current_version() remote_version = (manifest.get("version") or "").strip() if not remote_version or not version_less(current, remote_version): if remote_version == current: self._last_prompted_update_version = "" return if remote_version == self._last_prompted_update_version: return if self._update_prompt_active: return if hasattr(self, "_update_worker") and self._update_worker and self._update_worker.isRunning(): return self._last_prompted_update_version = remote_version self._update_prompt_active = True release_notes = (manifest.get("release_notes") or "").strip() msg = f"发现新版本 {remote_version}(当前 {current})" if release_notes: msg += f"\n\n{release_notes}" msg += "\n\n是否立即更新?" try: ret = QMessageBox.question( self, "检查更新", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes, ) finally: self._update_prompt_active = False if ret != QMessageBox.StandardButton.Yes: return delta = get_delta_for_current(manifest, current) has_delta = bool(delta) full_url = (manifest.get("full_installer_url") or manifest.get("download_url") or "").strip() self._update_manifest = manifest # 保存 manifest,增量失败时可用来发起全量更新 if has_delta: self._start_update_download( get_delta_download_url(delta), use_delta=True, expected_sha256=get_delta_expected_sha256(delta), delta_kind=get_delta_kind(delta), ) elif full_url: self._start_update_download(full_url, use_delta=False) else: QMessageBox.warning(self, "检查更新", "未配置下载地址,请稍后在「帮助」中手动检查更新。") def _on_welcome_help_requested(self): """欢迎页点击“帮助”时在光标位置弹出帮助菜单""" self.help_menu.popup(QCursor.pos()) def _refresh_recent_projects_menu(self): """刷新“文件 -> 最近的项目”子菜单,显示最近打开的项目列表,点击即可切换""" self.recent_projects_menu.clear() recent = load_recent_projects() if not recent: no_item = self.recent_projects_menu.addAction("(无最近项目)") no_item.setEnabled(False) return for path in recent: # 显示路径末尾一段(便于区分),完整路径放 tooltip display = path if len(path) <= 50 else "..." + path[-47:] act = self.recent_projects_menu.addAction(display) act.setToolTip(path) act.setData(path) act.triggered.connect(lambda checked, p=path: self._on_open_project(Path(p))) def _on_open_project_menu(self): from PyQt6.QtWidgets import QFileDialog path = QFileDialog.getExistingDirectory(self, "选择服务器项目目录", str(Path.cwd())) if path: self._on_open_project(Path(path)) def _on_open_project(self, path): path = Path(path) if not isinstance(path, Path) else path if not self.config.open_project(path): from PyQt6.QtWidgets import QMessageBox QMessageBox.warning(self, "打开项目", self.config.config_error or "无效项目目录") return apply_terminal_launch_config(self.config) save_recent_project(str(path)) self._build_project_content() self.stacked.setCurrentIndex(1) self.setWindowTitle("Server Manager v{} - {}".format(get_current_version(), path)) self.close_project_act.setEnabled(True) self._run_project_migrations() def _run_project_migrations(self): """打开项目后执行一次性版本迁移(幂等,已完成的迁移会自动跳过)。""" server_root = self.config.get('workspace', 'server_root', default='') or ( str(self.config.tool_dir) if self.config.tool_dir else '' ) if not server_root: return try: executed = run_startup_migrations(server_root, logger_func=self.console.append_output) except Exception as e: logger.error("执行启动迁移失败: %s", e, exc_info=True) return if executed: self.console.append_output("-" * 50 + "\n") def _on_close_project(self): self.config.tool_dir = None self.config.user_config_path = None self.config.tool_config_path = None self.config.config_error = "未打开项目" self.config.data = _empty_config_data() apply_terminal_launch_config(self.config) # 清空项目内容区域,下次打开项目会重建 while self.project_content_layout.count(): child = self.project_content_layout.takeAt(0) if child.widget(): child.widget().deleteLater() self.stacked.setCurrentIndex(0) self.setWindowTitle("Server Manager v{}".format(get_current_version())) self.close_project_act.setEnabled(False) def _on_about(self): """关于对话框""" info = get_version_info() version = info.get("version", "?") notes = info.get("release_notes", "").strip() msg = f"Server Manager\n\n版本: {version}" if notes: msg += f"\n\n{notes}" QMessageBox.about(self, "关于", msg) def _on_check_update(self): """检查更新:拉取 manifest(version, full_installer_url, delta_updates),可选全量或增量""" info = get_version_info() update_url = (info.get("update_url") or "").strip() if not update_url: QMessageBox.information( self, "检查更新", "未配置更新地址。\n请在 version.json 中设置 update_url(指向版本清单 JSON 的地址)。" ) return current = get_current_version() manifest = fetch_update_manifest(update_url) if not manifest: msg = ( "无法获取更新信息。\n\n" "请检查:\n" "1. 本机网络是否正常\n" "2. 更新服务器地址是否可访问(若为内网地址,请确保当前网络能访问)\n" "3. 稍后重试\n\n" "当前更新地址:\n{}" ).format(update_url[:80] + "..." if len(update_url) > 80 else update_url) QMessageBox.information(self, "检查更新", msg) return remote_version = manifest.get("version", "") if not version_less(current, remote_version): QMessageBox.information(self, "检查更新", f"当前已是最新版本({current})。") return release_notes = manifest.get("release_notes", "") full_url = (manifest.get("full_installer_url") or manifest.get("download_url") or "").strip() delta = get_delta_for_current(manifest, current) has_delta = bool(delta) if not full_url and not has_delta: QMessageBox.warning( self, "检查更新", f"发现新版本 {remote_version},但未配置 full_installer_url 或 delta_updates。\n\n{release_notes}" ) return btn_full = "立即更新(全量)" if has_delta else "立即更新" btn_delta = "立即更新(增量)" if has_delta else None msg = f"发现新版本 {remote_version}(当前 {current})\n\n{release_notes}" box = QMessageBox(self) box.setWindowTitle("检查更新") box.setText(msg) box.setIcon(QMessageBox.Icon.Information) box.addButton("取消", QMessageBox.ButtonRole.RejectRole) if btn_delta: box.addButton(btn_delta, QMessageBox.ButtonRole.AcceptRole) if full_url: box.addButton(btn_full, QMessageBox.ButtonRole.AcceptRole) box.exec() clicked = box.clickedButton().text() if clicked == "取消": return self._update_manifest = manifest if clicked == btn_delta and has_delta: self._start_update_download( get_delta_download_url(delta), use_delta=True, expected_sha256=get_delta_expected_sha256(delta), delta_kind=get_delta_kind(delta), ) else: self._start_update_download(full_url, use_delta=False) def _start_update_download( self, download_url: str, use_delta: bool = False, expected_sha256: Optional[str] = None, delta_kind: str = "", ): """下载更新包(全量 exe 或增量 patch),完成后全量则启动安装,增量则 bsdiff 打补丁并替换""" dest = Path(tempfile.gettempdir()) / "ServerManager_Update" dest.mkdir(parents=True, exist_ok=True) from urllib.parse import urlparse suffix = Path(urlparse(download_url).path).suffix if not suffix: suffix = ".smdelta" if use_delta and delta_kind == "file_zip" else (".patch" if use_delta else ".exe") dest_file = dest / ("ServerManager_patch" + suffix if use_delta else f"ServerManager_Setup{suffix}") self._update_delta_kind = delta_kind self._update_download_path = dest_file self._update_progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self) self._update_progress.setWindowTitle("检查更新") self._update_progress.setMinimumDuration(0) self._update_progress.setValue(0) self._update_worker = UpdateDownloadWorker( download_url, dest_file, is_delta=use_delta, expected_sha256=expected_sha256, ) self._update_worker.progress_signal.connect(self._on_update_progress) self._update_worker.finished_signal.connect(self._on_update_download_finished) self._update_progress.canceled.connect(self._on_update_canceled) self._update_worker.start() def _on_update_progress(self, percent: int): if hasattr(self, "_update_progress") and self._update_progress: self._update_progress.setValue(percent) def _on_update_canceled(self): if hasattr(self, "_update_worker") and self._update_worker and self._update_worker.isRunning(): self._update_worker.terminate() if hasattr(self, "_update_progress") and self._update_progress: self._update_progress.close() def _on_update_download_finished(self, path, is_delta: bool, expected_sha256: Optional[str]): if hasattr(self, "_update_progress") and self._update_progress: self._update_progress.close() if not path or not Path(path).exists(): QMessageBox.warning(self, "检查更新", "下载失败,请稍后重试或手动下载。") return if is_delta: QMessageBox.information( self, "检查更新", "增量包已下载,即将退出并重启以应用更新。" ) delta_kind = getattr(self, "_update_delta_kind", "") or "bsdiff_exe" if delta_kind == "file_zip": ok, reason = apply_file_delta_package(Path(path), expected_sha256 or "") else: ok, reason = apply_delta_patch(Path(path), expected_sha256 or "") if ok: pass # 成功时进程会退出 else: box = QMessageBox(self) box.setWindowTitle("检查更新") box.setIcon(QMessageBox.Icon.Warning) box.setText(f"增量更新应用失败:{reason}") btn_full = box.addButton("全量更新", QMessageBox.ButtonRole.AcceptRole) box.addButton("确定", QMessageBox.ButtonRole.RejectRole) box.exec() if box.clickedButton() == btn_full and getattr(self, "_update_manifest", None): manifest = self._update_manifest full_url = (manifest.get("full_installer_url") or manifest.get("download_url") or "").strip() if full_url: self._start_update_download(full_url, use_delta=False) else: QMessageBox.information( self, "检查更新", "更新包已下载,即将退出并重启以完成安装。" ) run_installer_and_exit(Path(path), silent=True) def _build_project_content(self): """创建或重建项目内容区(标签页 + 控制台)""" apply_terminal_launch_config(self.config) while self.project_content_layout.count(): child = self.project_content_layout.takeAt(0) if child.widget(): child.widget().deleteLater() self.project_splitter = QSplitter(Qt.Orientation.Vertical) self.project_splitter.setStyleSheet(""" QSplitter::handle { background-color: #3a4352; height: 6px; } QSplitter::handle:hover { background-color: #4f8cff; } """) self.tabs = QTabWidget() self.console = OutputConsole() self.command_tab = CommandTab(self.config, self.console) self.create_server_tab = CreateServerTab(self.config, self.console) self.server_manage_tab = ServerManageTab(self.config, self.console) self.log_viewer_tab = LogViewerTab(self.config, self.console) self.config_tab = ConfigTab(self.config, self.console) self.config_tab.config_saved.connect(self._on_config_saved) self._main_tab_icon_kinds = ["calendar", "plus", "server", "log", "settings"] self.tabs.addTab(self.command_tab, make_line_icon("calendar", "#aab4c3", 16), "控制台") self.tabs.addTab(self.create_server_tab, make_line_icon("plus", "#aab4c3", 16), "创建服务器") self.tabs.addTab(self.server_manage_tab, make_line_icon("server", "#aab4c3", 16), "服务器管理") self.tabs.addTab(self.log_viewer_tab, make_line_icon("log", "#aab4c3", 16), "日志查看") self.tabs.addTab(self.config_tab, make_line_icon("settings", "#aab4c3", 16), "工具设置") self._update_main_tab_icons(0) self.tabs.currentChanged.connect(self._on_tab_changed) self.tabs.setMinimumHeight(300) top_ip_label = CopyableIpLabel(get_local_ip()) top_ip_label.setStyleSheet(""" QLabel { color: #aab4c3; background-color: #2b313c; border: 1px solid #3a4352; border-radius: 6px; padding: 5px 12px; margin-right: 10px; } QLabel:hover { color: #4f8cff; background-color: #243247; border-color: #5f94db; } """) self.tabs.setCornerWidget(top_ip_label, Qt.Corner.TopRightCorner) self.project_splitter.addWidget(self.tabs) self.console_widget = QWidget() self.console_widget.setFixedHeight(self.CONSOLE_PANEL_HEIGHT) self.console_widget.setObjectName("consolePanel") self.console_widget.setStyleSheet(""" QWidget#consolePanel { background-color: #242932; border: 1px solid #3a4352; border-radius: 8px; } """) console_layout = QVBoxLayout(self.console_widget) console_layout.setContentsMargins(16, 10, 16, 12) console_layout.setSpacing(8) console_header = QHBoxLayout() console_icon = QLabel() console_icon.setPixmap(make_line_icon("log", "#c6d7ef", 18).pixmap(18, 18)) console_header.addWidget(console_icon) console_label = QLabel("输出日志") console_label.setStyleSheet("font-weight: bold; font-size: 13px; color: #e8eef7;") console_header.addWidget(console_label) console_header.addStretch() popup_btn = QPushButton("独立窗口") popup_btn.setMaximumWidth(108) popup_btn.clicked.connect(self.console.open_window) apply_button_style(popup_btn, "outline", compact=True) set_button_icon(popup_btn, "external", "#4f8cff", 16) console_header.addWidget(popup_btn) clear_btn = QPushButton("清空") clear_btn.setMaximumWidth(66) clear_btn.clicked.connect(self.console.clear_output) apply_button_style(clear_btn, "danger", compact=True) set_button_icon(clear_btn, "trash", "#ff5c6a", 16) console_header.addWidget(clear_btn) console_layout.addLayout(console_header) self.console.setMinimumHeight(260) console_layout.addWidget(self.console) self.project_splitter.addWidget(self.console_widget) self.project_splitter.setCollapsible(0, False) self.project_splitter.setCollapsible(1, False) self.project_splitter.setStretchFactor(0, 1) self.project_splitter.setStretchFactor(1, 0) self.project_splitter.setSizes([360, self.CONSOLE_PANEL_HEIGHT]) self.project_content_layout.addWidget(self.project_splitter) self._sync_console_panel_visibility(self.tabs.currentIndex()) QTimer.singleShot(0, lambda: self._sync_console_panel_visibility(self.tabs.currentIndex())) self.console.append_output("=" * 50 + "\n") self.console.append_output(" Server Manager v{}\n".format(get_current_version())) self.console.append_output("=" * 50 + "\n") self.console.append_output(f"本机IP: {get_local_ip()}\n") server_root = self.config.get('workspace', 'server_root', default='') run_dir = self.config.get('workspace', 'run_dir', default='') if server_root: self.console.append_output(f"服务器目录: {server_root}\n") else: self.console.append_output("[提示] 请在「工具设置」中配置服务器根目录\n") if run_dir: self.console.append_output(f"运行目录: {run_dir}\n") self.console.append_output("-" * 50 + "\n") QTimer.singleShot(500, self._init_node_status_cache) def _init_node_status_cache(self): """初始化本地节点状态缓存 首次启动时,批量查询所有本地节点状态并加载到缓存 """ cache = get_node_status_cache() # 如果缓存已初始化,跳过 if cache.is_initialized(): self.console.append_output("[缓存] 节点状态缓存已初始化,跳过\n") return # 获取本地服务器列表 server_root = self.config.get('workspace', 'server_root', default='') run_dir = self.config.get('workspace', 'run_dir', default='') if not run_dir and server_root: run_dir = str(Path(server_root) / 'run') if not run_dir: self.console.append_output("[缓存] 运行目录未配置,跳过缓存初始化\n") cache.set_initialized(True) return if not Path(run_dir).exists(): self.console.append_output(f"[缓存] 运行目录不存在: {run_dir},跳过缓存初始化\n") cache.set_initialized(True) return servers = get_server_list(run_dir) all_servers = [] for category in ['game', 'cross', 'login', 'client', 'center', 'other']: all_servers.extend(servers.get(category, [])) if not all_servers: self.console.append_output("[缓存] 未找到任何服务器,跳过缓存初始化\n") cache.set_initialized(True) return # 批量查询所有本地节点状态 self.console.append_output(f"[缓存] 正在批量检查 {len(all_servers)} 个节点状态...\n") if self._cache_init_worker and self._cache_init_worker.isRunning(): return cookie = self.config.get('server', 'cookie', default='ddxq2-node') worker = NodeStatusCheckWorker( all_servers, cookie, erl_path=self.config.get('erlang', 'r25_path', default=''), ) self._cache_init_worker = worker worker.finished_signal.connect( lambda results, total=len(all_servers): self._on_init_node_status_cache_finished(results, total) ) worker.finished.connect( lambda w=worker: setattr(self, '_cache_init_worker', None) if self._cache_init_worker is w else None ) worker.finished.connect(worker.deleteLater) worker.start() def _on_init_node_status_cache_finished(self, results, total: int): """本地节点状态缓存初始化完成。""" cache = get_node_status_cache() cache.batch_set(results) cache.set_initialized(True) online_count = sum(1 for v in results.values() if v) self.console.append_output(f"[缓存] 节点状态已加载,在线: {online_count}/{total}\n") if hasattr(self, 'server_manage_tab'): self.server_manage_tab.refresh_status_from_cache() def _on_tab_changed(self, index: int): """标签页切换时刷新状态显示""" self._update_main_tab_icons(index) self._sync_console_panel_visibility(index) # 切换到"服务器管理"标签页时(索引为2),从缓存刷新状态 if index == 2 and hasattr(self, 'server_manage_tab'): self.server_manage_tab.refresh_status_from_cache() # 切换到"日志查看"标签页时(索引为3),自动刷新服务器列表 if index == 3 and hasattr(self, "log_viewer_tab"): self.log_viewer_tab.refresh_server_list() def _update_main_tab_icons(self, current_index: int): """同步主导航图标颜色:选中蓝色,未选中灰色。""" if not hasattr(self, "_main_tab_icon_kinds") or not hasattr(self, "tabs"): return for i, kind in enumerate(self._main_tab_icon_kinds): color = "#4f8cff" if i == current_index else "#aab4c3" self.tabs.setTabIcon(i, make_line_icon(kind, color, 16)) def _sync_console_panel_visibility(self, index: int): """底部输出日志只在主「控制台」页签显示。""" if not hasattr(self, "console_widget"): return show_console = index == 0 self.console_widget.setVisible(show_console) if hasattr(self, "project_splitter"): if show_console: self.console_widget.setFixedHeight(self.CONSOLE_PANEL_HEIGHT) self.project_splitter.setSizes([360, self.CONSOLE_PANEL_HEIGHT]) else: self.project_splitter.setSizes([1, 0]) def _on_config_saved(self): """配置保存后刷新其他页签""" # 刷新创建服务器页签的数据库配置 if hasattr(self, 'create_server_tab'): self.create_server_tab.refresh_config() # 刷新 Server management 页签 if hasattr(self, 'server_manage_tab'): self.server_manage_tab.refresh_server_list() if hasattr(self, "log_viewer_tab"): self.log_viewer_tab.refresh_server_list() def closeEvent(self, event): """关闭窗口时的处理 抑制 PyInstaller 打包后的临时目录删除警告 当有 Erlang 节点运行时,临时目录可能无法删除,但这不影响使用 """ import sys import os # 如果是 PyInstaller 打包的程序 if getattr(sys, 'frozen', False) and sys.platform == 'win32': try: import ctypes # 设置错误模式,抑制所有系统错误对话框 SEM_FAILCRITICALERRORS = 0x0001 SEM_NOGPFAULTERRORBOX = 0x0002 SEM_NOALIGNMENTFAULTEXCEPT = 0x0004 SEM_NOOPENFILEERRORBOX = 0x8000 error_mode = (SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOALIGNMENTFAULTEXCEPT | SEM_NOOPENFILEERRORBOX) ctypes.windll.kernel32.SetErrorMode(error_mode) # 禁用 Windows 错误报告 try: ctypes.windll.kernel32.SetUnhandledExceptionFilter(None) except Exception: pass except Exception: pass event.accept() # 强制退出,跳过 PyInstaller 的清理步骤 if getattr(sys, 'frozen', False): # 使用 QTimer 延迟调用 os._exit,确保 Qt 事件循环正常结束 from PyQt6.QtCore import QTimer QTimer.singleShot(100, lambda: os._exit(0)) def check_config_and_show_dialog(config: Config) -> bool: """检查配置并显示配置对话框 Returns: True 如果配置有效,False 如果用户取消 """ if config.is_configured(): return True # 显示配置提示对话框 msg = QMessageBox() msg.setWindowTitle("首次配置") msg.setIcon(QMessageBox.Icon.Information) msg.setText("欢迎使用 Server Manager!\n\n检测到工具尚未配置,请先完成以下设置:") missing = config.get_missing_config() detail = "\n".join(f"• {item}" for item in missing) if missing else "• 服务器根目录" msg.setInformativeText(f"缺失配置项:\n{detail}\n\n点击\"确定\"进入工具设置页面进行配置。") msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) msg.setDefaultButton(QMessageBox.StandardButton.Ok) result = msg.exec() return result == QMessageBox.StandardButton.Ok