2026-02-26 20:42:22 +08:00
|
|
|
|
# -*- 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-26 20:55:59 +08:00
|
|
|
|
def _get_worker_launcher() -> tuple[str, str, bool]:
|
|
|
|
|
|
"""返回 (可执行路径, 工作目录, 是否用 --worker 模式)。"""
|
2026-02-26 20:42:22 +08:00
|
|
|
|
if _is_frozen():
|
|
|
|
|
|
exe_dir = os.path.dirname(sys.executable)
|
2026-02-26 20:55:59 +08:00
|
|
|
|
return sys.executable, exe_dir, True
|
2026-02-26 20:42:22 +08:00
|
|
|
|
project_root = get_project_root()
|
2026-02-26 20:55:59 +08:00
|
|
|
|
return sys.executable, project_root, False
|
2026-02-26 20:42:22 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_worker(server_url: str, worker_id: str, worker_name: str, log_callback) -> subprocess.Popen | None:
|
2026-02-26 20:55:59 +08:00
|
|
|
|
"""启动 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:
|
2026-02-26 20:42:22 +08:00
|
|
|
|
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()
|