Files
server_manager/src/views/main_window.py
T
2026-06-09 14:01:20 +08:00

919 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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):
"""检查更新:拉取 manifestversion, 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