379 lines
13 KiB
Python
379 lines
13 KiB
Python
# -*- 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()
|