Files
boss_dp/client_gui/main.py
2026-02-26 20:55:59 +08:00

379 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
客户端 GUI 主程序PyQt5
供线下有比特浏览器的电脑使用,非技术用户可通过界面完成「更新」和「启动」。
"""
from __future__ import annotations
import os
import queue
import shutil
import subprocess
import sys
import threading
from PyQt5.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QVBoxLayout,
QHBoxLayout,
QGridLayout,
QGroupBox,
QLabel,
QLineEdit,
QPushButton,
QTextEdit,
QProgressBar,
QMessageBox,
QFrame,
)
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject, QEvent
from PyQt5.QtGui import QFont, QTextCursor
from .config_store import load_config, save_config, get_project_root
# 日志缓冲
LOG_BATCH_CHARS = 2048
LOG_MAX_LINES = 2000
LOG_DRAIN_INTERVAL_MS = 120
def do_git_pull(project_root: str, log_callback) -> bool:
"""在项目目录执行 git pull。"""
git = shutil.which("git")
if not git:
log_callback("错误:未找到 git请先安装 Git 或在已克隆的代码目录中手动更新。\n")
return False
try:
proc = subprocess.run(
[git, "pull"],
cwd=project_root,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if proc.stdout:
log_callback(proc.stdout)
if proc.stderr:
log_callback(proc.stderr)
if proc.returncode == 0:
log_callback("更新完成。\n")
return True
log_callback(f"更新失败,返回码: {proc.returncode}\n")
return False
except Exception as e:
log_callback(f"更新出错: {e}\n")
return False
def _is_frozen() -> bool:
return getattr(sys, "frozen", False)
def _get_worker_launcher() -> tuple[str, str, bool]:
"""返回 (可执行路径, 工作目录, 是否用 --worker 模式)。"""
if _is_frozen():
exe_dir = os.path.dirname(sys.executable)
return sys.executable, exe_dir, True
project_root = get_project_root()
return sys.executable, project_root, False
def run_worker(server_url: str, worker_id: str, worker_name: str, log_callback) -> subprocess.Popen | None:
"""启动 Worker 子进程(同一 exe 的 --worker 模式或 python -m worker.main"""
launcher, cwd, use_worker_flag = _get_worker_launcher()
if use_worker_flag:
cmd = [launcher, "--worker", "--server", server_url, "--worker-id", worker_id, "--worker-name", worker_name]
env = os.environ
encoding = "gbk" if sys.platform == "win32" else "utf-8"
else:
cmd = [
launcher, "-m", "worker.main",
"--server", server_url,
"--worker-id", worker_id,
"--worker-name", worker_name,
]
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
encoding = "utf-8"
try:
return subprocess.Popen(
cmd, cwd=cwd, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding=encoding, errors="replace", bufsize=1,
)
except Exception as e:
log_callback(f"启动失败: {e}\n")
return None
def _reader_thread(proc: subprocess.Popen, log_queue: queue.Queue) -> None:
"""后台线程:读取子进程 stdout放入队列。避免主线程阻塞。"""
try:
for line in iter(proc.stdout.readline, ""):
if line:
log_queue.put(line)
except Exception:
pass
class LogWorker(QObject):
"""日志队列批量渲染,避免卡顿。"""
batch_ready = pyqtSignal(str)
def __init__(self, log_queue: queue.Queue, parent=None):
super().__init__(parent)
self.log_queue = log_queue
self.timer = QTimer(self)
self.timer.timeout.connect(self._drain)
self.timer.start(LOG_DRAIN_INTERVAL_MS)
def _drain(self):
if self.log_queue.empty():
return
batch = []
size = 0
try:
while size < LOG_BATCH_CHARS:
batch.append(self.log_queue.get_nowait())
size += len(batch[-1])
except queue.Empty:
pass
if batch:
self.batch_ready.emit("".join(batch))
class ClientGUI(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("BOSS 直聘 Worker 客户端")
self.setMinimumSize(600, 460)
self.resize(620, 500)
self.config = load_config()
self.worker_proc: subprocess.Popen | None = None
self._poll_timer: QTimer | None = None
self._log_queue: queue.Queue[str] = queue.Queue()
self._build_ui()
self._log_worker = LogWorker(self._log_queue, self)
self._log_worker.batch_ready.connect(self._append_log_batch)
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setSpacing(8)
# 配置区
cfg_group = QGroupBox("连接配置")
cfg_layout = QGridLayout(cfg_group)
cfg_layout.addWidget(QLabel("服务器地址:"), 0, 0)
self.entry_server = QLineEdit()
self.entry_server.setPlaceholderText("ws://8.137.99.82:9000/ws")
self.entry_server.setText(self.config.get("server_url", ""))
cfg_layout.addWidget(self.entry_server, 0, 1)
cfg_layout.addWidget(QLabel("Worker ID:"), 1, 0)
self.entry_worker_id = QLineEdit()
self.entry_worker_id.setText(self.config.get("worker_id", ""))
cfg_layout.addWidget(self.entry_worker_id, 1, 1)
cfg_layout.addWidget(QLabel("Worker 名称:"), 2, 0)
self.entry_worker_name = QLineEdit()
self.entry_worker_name.setText(self.config.get("worker_name", ""))
cfg_layout.addWidget(self.entry_worker_name, 2, 1)
layout.addWidget(cfg_group)
# 操作按钮
btn_layout = QHBoxLayout()
self.btn_update = QPushButton("更新代码")
self.btn_update.setCursor(Qt.PointingHandCursor)
self.btn_update.clicked.connect(self._on_update)
btn_layout.addWidget(self.btn_update)
self.btn_start = QPushButton("启动")
self.btn_start.setCursor(Qt.PointingHandCursor)
self.btn_start.clicked.connect(self._on_start)
btn_layout.addWidget(self.btn_start)
self.btn_stop = QPushButton("停止")
self.btn_stop.setCursor(Qt.PointingHandCursor)
self.btn_stop.clicked.connect(self._on_stop)
self.btn_stop.setEnabled(False)
btn_layout.addWidget(self.btn_stop)
btn_layout.addStretch()
layout.addLayout(btn_layout)
# 进度条(更新时显示)
self.progress = QProgressBar()
self.progress.setRange(0, 0) # indeterminate
self.progress.setVisible(False)
layout.addWidget(self.progress)
# 日志区
log_group = QGroupBox("运行日志")
log_layout = QVBoxLayout(log_group)
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setFont(QFont("Consolas", 9))
self.log_text.setStyleSheet(
"QTextEdit { background-color: #f8fafc; color: #1e293b; border: 1px solid #e2e8f0; border-radius: 4px; }"
)
log_layout.addWidget(self.log_text)
layout.addWidget(log_group, 1)
# 状态栏
status_frame = QFrame()
status_layout = QHBoxLayout(status_frame)
status_layout.setContentsMargins(0, 4, 0, 0)
self.status_indicator = QLabel("")
self.status_indicator.setStyleSheet("color: #22c55e; font-size: 12px;")
status_layout.addWidget(self.status_indicator)
self.status_label = QLabel("就绪")
status_layout.addWidget(self.status_label)
status_layout.addStretch()
layout.addWidget(status_frame)
def _save_config(self):
self.config["server_url"] = self.entry_server.text().strip()
self.config["worker_id"] = self.entry_worker_id.text().strip()
self.config["worker_name"] = self.entry_worker_name.text().strip()
save_config(self.config)
def _log(self, msg: str):
self._log_queue.put(msg)
def _append_log_batch(self, text: str):
cursor = self.log_text.textCursor()
cursor.movePosition(QTextCursor.End)
self.log_text.setTextCursor(cursor)
self.log_text.insertPlainText(text)
# 限制行数
block = self.log_text.document().firstBlock()
count = 0
while block.isValid():
count += 1
block = block.next()
if count > LOG_MAX_LINES:
cursor = self.log_text.textCursor()
cursor.movePosition(QTextCursor.Start)
for _ in range(count - LOG_MAX_LINES):
cursor.movePosition(QTextCursor.Down, QTextCursor.KeepAnchor)
cursor.removeSelectedText()
self.log_text.verticalScrollBar().setValue(self.log_text.verticalScrollBar().maximum())
def _set_status(self, text: str, color: str = "#22c55e"):
self.status_label.setText(text)
self.status_indicator.setStyleSheet(f"color: {color}; font-size: 12px;")
def _on_update(self):
self._save_config()
self.btn_update.setEnabled(False)
self._set_status("更新中…", "#f59e0b")
self.progress.setVisible(True)
self._log("正在更新代码git pull...\n")
def task():
ok = do_git_pull(get_project_root(), self._log)
QApplication.instance().postEvent(
self, _FinishUpdateEvent(ok)
)
threading.Thread(target=task, daemon=True).start()
def event(self, e):
if isinstance(e, _FinishUpdateEvent):
self.progress.setVisible(False)
self.btn_update.setEnabled(True)
self._set_status("更新完成" if e.ok else "就绪", "#22c55e" if e.ok else "#ef4444")
if e.ok:
QMessageBox.information(self, "更新", "代码更新成功")
else:
QMessageBox.warning(self, "更新", "更新失败,请检查网络或 Git 配置")
return True
return super().event(e)
def _on_start(self):
self._save_config()
server_url = self.entry_server.text().strip() or "ws://8.137.99.82:9000/ws"
worker_id = self.entry_worker_id.text().strip() or "worker-1"
worker_name = self.entry_worker_name.text().strip() or "本机"
self._set_status("启动中…", "#3b82f6")
self._log("正在启动 Worker...\n")
proc = run_worker(server_url, worker_id, worker_name, self._log)
if proc is None:
self._set_status("就绪", "#22c55e")
return
self.worker_proc = proc
self.btn_start.setEnabled(False)
self.btn_stop.setEnabled(True)
self._set_status("运行中", "#22c55e")
# 后台线程读取 stdout避免主线程阻塞
t = threading.Thread(target=_reader_thread, args=(proc, self._log_queue), daemon=True)
t.start()
# 定时检查进程是否退出(非阻塞)
self._exit_check_timer = QTimer(self)
self._exit_check_timer.timeout.connect(self._check_worker_exit)
self._exit_check_timer.start(300)
def _check_worker_exit(self):
if self.worker_proc is None:
return
if self.worker_proc.poll() is not None:
self._exit_check_timer.stop()
self._exit_check_timer = None
self._on_worker_exit()
def _on_worker_exit(self):
self.worker_proc = None
self.btn_start.setEnabled(True)
self.btn_stop.setEnabled(False)
self._set_status("已停止", "#6b7280")
self._log("Worker 已退出。\n")
def _on_stop(self):
if self.worker_proc:
self._set_status("正在停止…", "#f59e0b")
self.worker_proc.terminate()
self._log("正在停止 Worker...\n")
def closeEvent(self, event):
if self.worker_proc and self.worker_proc.poll() is None:
reply = QMessageBox.question(
self, "确认", "Worker 正在运行,确定要退出吗?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
)
if reply == QMessageBox.Yes:
self.worker_proc.terminate()
else:
event.ignore()
return
event.accept()
class _FinishUpdateEvent(QEvent):
Type = QEvent.Type(QEvent.registerEventType())
def __init__(self, ok: bool):
super().__init__(_FinishUpdateEvent.Type)
self.ok = ok
def main():
app = QApplication(sys.argv)
w = ClientGUI()
w.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()