仓库初始化

This commit is contained in:
lwt
2026-05-22 00:16:08 +08:00
commit 95c96dce42
118 changed files with 23005 additions and 0 deletions
+872
View File
@@ -0,0 +1,872 @@
# -*- 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
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.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()
# 菜单栏:保证在窗口内可见(非系统原生菜单),便于看到“文件”“帮助”
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)
# 首次打开后延迟检查远端版本清单,版本不一致则弹窗询问是否更新
if not self._startup_update_check_started:
self._startup_update_check_started = True
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
self._startup_check_worker = UpdateCheckWorker(update_url, self)
self._startup_check_worker.finished_signal.connect(self._on_startup_update_checked)
self._startup_check_worker.start()
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 remote_version == current:
return
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是否立即更新?"
ret = QMessageBox.question(
self,
"检查更新",
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if ret != QMessageBox.StandardButton.Yes:
return
delta = get_delta_for_current(manifest, current)
has_delta = delta and delta.get("patch_url") and delta.get("new_exe_sha256")
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(delta["patch_url"], use_delta=True, expected_sha256=delta.get("new_exe_sha256"))
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.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 and delta.get("patch_url") and delta.get("new_exe_sha256"))
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(
delta["patch_url"],
use_delta=True,
expected_sha256=delta.get("new_exe_sha256"),
)
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,
):
"""下载更新包(全量 exe 或增量 patch),完成后全量则启动安装,增量则 bsdiff 打补丁并替换"""
dest = Path(tempfile.gettempdir()) / "ServerManager_Update"
dest.mkdir(parents=True, exist_ok=True)
suffix = Path(download_url).suffix or (".patch" if use_delta else ".exe")
dest_file = dest / ("ServerManager_patch" + suffix if use_delta else f"ServerManager_Setup{suffix}")
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 and expected_sha256:
QMessageBox.information(
self,
"检查更新",
"增量包已下载,即将退出并重启以应用补丁。"
)
ok, reason = apply_delta_patch(Path(path), expected_sha256)
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