仓库初始化
This commit is contained in:
@@ -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):
|
||||
"""检查更新:拉取 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 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
|
||||
Reference in New Issue
Block a user