Files
boss_dp/client_gui/main.py
ddrwode 7b351039f8 haha
2026-03-06 10:47:46 +08:00

537 lines
18 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 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()