# -*- coding: utf-8 -*- """ 客户端 GUI 主程序(PyQt5)。 供线下有比特浏览器的电脑使用,非技术用户可通过界面完成「更新」和「启动」。 """ from __future__ import annotations import os import queue import shutil import subprocess import sys import threading from typing import Optional from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QLabel, QLineEdit, QPushButton, QTextEdit, QProgressBar, QComboBox, 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 from worker.bit_browser import BitBrowserAPI # 日志缓冲 LOG_BATCH_CHARS = 2048 LOG_MAX_LINES = 2000 LOG_DRAIN_INTERVAL_MS = 120 def _browser_online_flag(browser: dict) -> Optional[bool]: for key in ("isRunning", "isOpen", "open", "is_open"): if key in browser: return bool(browser.get(key)) status = str(browser.get("status", "")).strip().lower() if not status: return None if status in {"1", "true", "online", "running", "open", "已打开", "打开", "在线"}: return True if status in {"0", "false", "offline", "stopped", "closed", "已关闭", "关闭", "离线"}: return False return None def fetch_browser_profiles() -> list[dict]: """从本地比特浏览器读取可选环境;若能识别在线状态则优先返回在线环境。""" bit_api = BitBrowserAPI() items = bit_api.list_browsers(page_size=200) profiles = [] for item in items: if not isinstance(item, dict): continue profiles.append({ "id": str(item.get("id", "") or "").strip(), "name": str(item.get("name", "") or "").strip(), "remark": str(item.get("remark", "") or "").strip(), "online": _browser_online_flag(item), }) online_profiles = [item for item in profiles if item.get("online") is True] return online_profiles or profiles 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, sync_browser_id: str = "", sync_browser_name: str = "", ) -> 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" if sync_browser_id: cmd.extend(["--sync-browser-id", sync_browser_id]) if sync_browser_name: cmd.extend(["--sync-browser-name", sync_browser_name]) 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) QTimer.singleShot(0, self._on_refresh_browsers) 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) cfg_layout.addWidget(QLabel("同步环境:"), 3, 0) browser_layout = QHBoxLayout() self.combo_sync_browser = QComboBox() self.combo_sync_browser.setMinimumWidth(280) browser_layout.addWidget(self.combo_sync_browser, 1) self.btn_refresh_browsers = QPushButton("刷新环境") self.btn_refresh_browsers.setCursor(Qt.PointingHandCursor) self.btn_refresh_browsers.clicked.connect(lambda _checked=False: self._on_refresh_browsers(notify=True)) browser_layout.addWidget(self.btn_refresh_browsers) cfg_layout.addLayout(browser_layout, 3, 1) self._set_browser_placeholder("正在加载浏览器环境…") 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() browser = self._selected_browser() self.config["sync_browser_id"] = browser.get("id", "") self.config["sync_browser_name"] = browser.get("name", "") 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 if isinstance(e, _BrowserProfilesEvent): self.btn_refresh_browsers.setEnabled(True) self._apply_browser_profiles(e.profiles) if e.error: self._log(f"读取浏览器环境失败: {e.error}\n") if e.notify: QMessageBox.warning(self, "浏览器环境", e.error) elif e.notify: self._log(f"已加载 {len(e.profiles)} 个浏览器环境。\n") return True return super().event(e) def _set_browser_placeholder(self, text: str): self.combo_sync_browser.clear() self.combo_sync_browser.addItem(text, None) def _selected_browser(self) -> dict: data = self.combo_sync_browser.currentData() return data if isinstance(data, dict) else {} def _browser_label(self, browser: dict) -> str: name = browser.get("name", "") or "(未命名环境)" remark = browser.get("remark", "") return f"{name} [{remark}]" if remark else name def _apply_browser_profiles(self, profiles: list[dict]): self.combo_sync_browser.clear() if not profiles: self._set_browser_placeholder("未找到可用浏览器环境") return for browser in profiles: self.combo_sync_browser.addItem(self._browser_label(browser), browser) saved_id = str(self.config.get("sync_browser_id", "") or "").strip() saved_name = str(self.config.get("sync_browser_name", "") or "").strip() target_index = 0 for index in range(self.combo_sync_browser.count()): browser = self.combo_sync_browser.itemData(index) if not isinstance(browser, dict): continue if saved_id and browser.get("id") == saved_id: target_index = index break if not saved_id and saved_name and browser.get("name") == saved_name: target_index = index break self.combo_sync_browser.setCurrentIndex(target_index) def _on_refresh_browsers(self, notify: bool = False): self.btn_refresh_browsers.setEnabled(False) self._set_browser_placeholder("正在加载浏览器环境…") if notify: self._log("正在刷新浏览器环境列表...\n") def task(): try: profiles = fetch_browser_profiles() error = "" except Exception as err: profiles = [] error = str(err) QApplication.instance().postEvent(self, _BrowserProfilesEvent(profiles, error, notify)) threading.Thread(target=task, daemon=True).start() 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 "本机" browser = self._selected_browser() sync_browser_id = str(browser.get("id", "") or "").strip() sync_browser_name = str(browser.get("name", "") or "").strip() self._set_status("启动中…", "#3b82f6") if sync_browser_name: self._log(f"正在启动 Worker,并同步筛选环境:{sync_browser_name}\n") else: self._log("正在启动 Worker...\n") proc = run_worker( server_url, worker_id, worker_name, self._log, sync_browser_id=sync_browser_id, sync_browser_name=sync_browser_name, ) 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 class _BrowserProfilesEvent(QEvent): Type = QEvent.Type(QEvent.registerEventType()) def __init__(self, profiles: list[dict], error: str = "", notify: bool = False): super().__init__(_BrowserProfilesEvent.Type) self.profiles = profiles self.error = error self.notify = notify def main(): app = QApplication(sys.argv) w = ClientGUI() w.show() sys.exit(app.exec_()) if __name__ == "__main__": main()