919 lines
40 KiB
Python
919 lines
40 KiB
Python
# -*- 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
|