# -*- coding: utf-8 -*-
"""PySide6 desktop shell for the WeCom customer-service assistant."""
from __future__ import annotations
import json
import os
import queue
import signal
import sys
import threading
import time
from pathlib import Path
from PySide6.QtCore import (
QEasingCurve,
QPoint,
QPropertyAnimation,
QRect,
QTimer,
Qt,
QUrl,
Signal,
)
from PySide6.QtGui import QColor, QDesktopServices, QFont, QIcon, QPainter, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QDialog,
QDoubleSpinBox,
QFrame,
QGraphicsDropShadowEffect,
QGraphicsOpacityEffect,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QScrollArea,
QSpinBox,
QStackedWidget,
QTableWidget,
QTableWidgetItem,
QTextEdit,
QVBoxLayout,
QWidget,
)
from PySide6.QtWebEngineCore import (
QWebEnginePage,
QWebEngineProfile,
QWebEngineScript,
QWebEngineSettings,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
try:
import __main__ as _legacy
if not hasattr(_legacy, "BotThread"):
raise ImportError
except ImportError:
import wechat_gui as _legacy
BotThread = _legacy.BotThread
LogQueue = _legacy.LogQueue
SCRIPT_DIR = Path(__file__).resolve().parent
APP_SETTINGS_FILE = SCRIPT_DIR / "app_settings.json"
CUSTOMER_SERVICE_URL = "http://kf.zhenyangtang.com.cn/"
WIN_TITLE = "甄养堂 · 企微客服助手"
WECOM_WAITING_MESSAGE = (
"企业微信主界面未显示、未在前台或已最小化,正在尝试切到前台;"
"本轮失败时下一轮将自动重试。"
)
COLORS = {
"canvas": "#F4F7F5",
"surface": "#FFFFFF",
"surface_alt": "#F8FAF9",
"sidebar": "#10271F",
"sidebar_alt": "#17372C",
"ink": "#17251F",
"muted": "#617269",
"faint": "#8A9991",
"line": "#DDE7E1",
"line_soft": "#EAF0ED",
"accent": "#10A57A",
"accent_dark": "#087A59",
"accent_soft": "#DDF5EC",
"success": "#118764",
"warning": "#B67820",
"danger": "#D85260",
"danger_soft": "#FBEAEC",
}
APP_QSS = """
* {
font-family: "Microsoft YaHei UI", "Segoe UI";
font-size: 14px;
color: #17251F;
}
QMainWindow, QWidget#AppRoot, QWidget#PageRoot, QScrollArea#PageScroll,
QScrollArea#PageScroll > QWidget > QWidget {
background: #F4F7F5;
}
QDialog#SessionDetailDialog { background: #F4F7F5; }
QFrame#Sidebar {
background: #FCFDFC;
border: none;
border-right: 1px solid #DDE7E1;
}
QFrame#Sidebar QLabel { color: #42574D; }
QFrame#Sidebar QLabel#Brand {
color: #087A59;
font-family: "Cascadia Mono", "Microsoft YaHei UI";
font-size: 22px;
font-weight: 700;
letter-spacing: 1px;
}
QFrame#Sidebar QLabel#BrandSub { color: #6C7D74; font-size: 13px; }
QFrame#Sidebar QLabel#SectionLabel {
color: #8A9991;
font-family: "Cascadia Mono";
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
}
QFrame#SideStatus {
background: #EEF8F3;
border: 1px solid #CFE9DE;
border-radius: 12px;
}
QFrame#Sidebar QLabel#SideStatusTitle { color: #17372C; font-size: 15px; font-weight: 700; }
QFrame#Sidebar QLabel#SideStatusHint { color: #6C7D74; font-size: 12px; }
QPushButton#NavButton {
background: transparent;
color: #5D7067;
border: none;
border-left: 3px solid transparent;
border-radius: 0px;
text-align: left;
padding: 13px 16px;
font-size: 15px;
}
QPushButton#NavButton:hover { background: #F0F6F3; color: #17372C; }
QPushButton#NavButton:checked {
background: #DDF5EC;
color: #087A59;
border-left-color: #10A57A;
font-weight: 700;
}
QPushButton#PrimaryButton, QPushButton#SidePrimary {
background: #10A57A;
color: #FFFFFF;
border: none;
border-radius: 10px;
padding: 11px 20px;
font-weight: 700;
}
QPushButton#PrimaryButton:hover, QPushButton#SidePrimary:hover { background: #087A59; }
QPushButton#PrimaryButton:pressed, QPushButton#SidePrimary:pressed { background: #06684B; }
QPushButton#PrimaryButton:disabled, QPushButton#SidePrimary:disabled {
background: #C8D7D0; color: #F7F9F8;
}
QPushButton#SecondaryButton {
background: #FFFFFF;
color: #42574D;
border: 1px solid #D7E2DC;
border-radius: 10px;
padding: 10px 18px;
}
QPushButton#SecondaryButton:hover { background: #F0F6F3; border-color: #BFD3C9; }
QPushButton#DangerButton {
background: #FBEAEC;
color: #C74452;
border: 1px solid #F2CDD2;
border-radius: 10px;
padding: 10px 18px;
font-weight: 600;
}
QPushButton#DangerButton:hover { background: #D85260; color: #FFFFFF; }
QFrame#Card, QFrame#HeroCard, QFrame#MetricCard {
background: #FFFFFF;
border: 1px solid #DDE7E1;
border-radius: 16px;
}
QFrame#CustomerBubble {
background: #FFFFFF;
border: 1px solid #DDE7E1;
border-radius: 13px;
}
QFrame#AssistantBubble {
background: #DDF5EC;
border: 1px solid #BFE8D8;
border-radius: 13px;
}
QFrame#SystemBubble {
background: #EEF2F0;
border: 1px solid #DDE7E1;
border-radius: 11px;
}
QLabel#MessageRole { color: #087A59; font-size: 12px; font-weight: 700; }
QLabel#MessageTime { color: #91A097; font-size: 11px; }
QLabel#MessageContent { color: #17251F; font-size: 14px; }
QFrame#HeroCard { background: #FBFDFC; }
QLabel#Eyebrow {
color: #0B8B67;
font-family: "Cascadia Mono";
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
}
QLabel#PageTitle { color: #17251F; font-size: 28px; font-weight: 700; }
QLabel#PageSubtitle { color: #687A71; font-size: 14px; }
QLabel#CardTitle { color: #17251F; font-size: 17px; font-weight: 700; }
QLabel#CardSubtitle { color: #6E7F76; font-size: 13px; }
QLabel#MetricValue {
color: #17251F;
font-family: "Cascadia Mono";
font-size: 30px;
font-weight: 700;
}
QLabel#MetricLabel { color: #6C7D74; font-size: 13px; }
QLabel#MetricMeta { color: #96A39C; font-size: 12px; }
QLabel#HeroStatus { color: #17251F; font-size: 30px; font-weight: 700; }
QLabel#HeroHint { color: #687A71; font-size: 14px; }
QLabel#SuccessText { color: #118764; font-weight: 600; }
QLabel#WarningText { color: #B67820; font-weight: 600; }
QLabel#DangerText { color: #D85260; font-weight: 600; }
QLineEdit, QPlainTextEdit, QTextEdit, QSpinBox, QDoubleSpinBox {
background: #FBFCFB;
color: #17251F;
border: 1px solid #D7E2DC;
border-radius: 9px;
padding: 9px 11px;
selection-background-color: #BDEBDC;
}
QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus,
QSpinBox:focus, QDoubleSpinBox:focus {
background: #FFFFFF;
border: 2px solid #10A57A;
padding: 8px 10px;
}
QLineEdit[readOnly="true"] { background: #F2F5F3; color: #798880; }
QCheckBox { spacing: 10px; font-size: 14px; }
QCheckBox::indicator {
width: 38px; height: 21px;
border-radius: 10px;
background: #C9D6D0;
}
QCheckBox::indicator:checked { background: #10A57A; }
QTableWidget {
background: #FFFFFF;
alternate-background-color: #F8FAF9;
border: 1px solid #DDE7E1;
border-radius: 12px;
gridline-color: #EAF0ED;
selection-background-color: #DDF5EC;
selection-color: #17251F;
}
QHeaderView::section {
background: #F2F6F4;
color: #607168;
border: none;
border-bottom: 1px solid #DDE7E1;
padding: 10px;
font-weight: 600;
}
QScrollBar:vertical { width: 10px; background: transparent; margin: 3px; }
QScrollBar::handle:vertical { background: #C7D5CE; border-radius: 4px; min-height: 30px; }
QScrollBar::handle:vertical:hover { background: #9EB5AA; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }
QProgressBar { background: transparent; border: none; max-height: 3px; }
QProgressBar::chunk { background: #10A57A; }
QToolTip {
background: #17372C; color: #FFFFFF; border: none; padding: 7px;
}
"""
def _button(text: str, kind: str = "secondary") -> QPushButton:
button = QPushButton(text)
names = {
"primary": "PrimaryButton",
"danger": "DangerButton",
"side": "SidePrimary",
}
button.setObjectName(names.get(kind, "SecondaryButton"))
button.setCursor(Qt.PointingHandCursor)
return button
def _shadow(widget: QWidget, blur: int = 28, y: int = 8, alpha: int = 24) -> None:
effect = QGraphicsDropShadowEffect(widget)
effect.setBlurRadius(blur)
effect.setOffset(0, y)
effect.setColor(QColor(22, 58, 43, alpha))
widget.setGraphicsEffect(effect)
def _label(text: str, name: str = "") -> QLabel:
item = QLabel(text)
if name:
item.setObjectName(name)
item.setWordWrap(True)
return item
def _page_header(index: str, title: str, subtitle: str) -> QVBoxLayout:
layout = QVBoxLayout()
layout.setSpacing(5)
layout.addWidget(_label(f"{index} CONTROL CENTER", "Eyebrow"))
layout.addWidget(_label(title, "PageTitle"))
layout.addWidget(_label(subtitle, "PageSubtitle"))
return layout
def _card(title: str = "", subtitle: str = "") -> tuple[QFrame, QVBoxLayout]:
frame = QFrame()
frame.setObjectName("Card")
layout = QVBoxLayout(frame)
layout.setContentsMargins(22, 20, 22, 20)
layout.setSpacing(11)
if title:
layout.addWidget(_label(title, "CardTitle"))
if subtitle:
layout.addWidget(_label(subtitle, "CardSubtitle"))
return frame, layout
class FadingStack(QStackedWidget):
def setCurrentIndexAnimated(self, index: int) -> None:
if index == self.currentIndex():
return
self.setCurrentIndex(index)
page = self.currentWidget()
effect = QGraphicsOpacityEffect(page)
page.setGraphicsEffect(effect)
animation = QPropertyAnimation(effect, b"opacity", page)
animation.setDuration(220)
animation.setStartValue(0.25)
animation.setEndValue(1.0)
animation.setEasingCurve(QEasingCurve.OutCubic)
animation.finished.connect(lambda: page.setGraphicsEffect(None))
page._fade_animation = animation
animation.start()
class PortalWebView(QWebEngineView):
def createWindow(self, _window_type):
return self
class PortalPage(QWidget):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setObjectName("PageRoot")
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.hide()
layout.addWidget(self.progress)
self.view = PortalWebView()
self.view.setFocusPolicy(Qt.StrongFocus)
self.view.settings().setAttribute(
QWebEngineSettings.WebAttribute.LocalStorageEnabled, True
)
self.view.settings().setAttribute(
QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True
)
profile_root = Path(os.environ.get("LOCALAPPDATA", SCRIPT_DIR)) / "ZhenYangTangRPA" / "QtPortal"
profile_root.mkdir(parents=True, exist_ok=True)
self.profile = QWebEngineProfile("zhen-ai-portal", self)
self.profile.setPersistentStoragePath(str(profile_root / "storage"))
self.profile.setCachePath(str(profile_root / "cache"))
self.profile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
)
self.page = QWebEnginePage(self.profile, self.view)
self.view.setPage(self.page)
self._install_light_theme()
self.view.loadStarted.connect(self._load_started)
self.view.loadProgress.connect(self.progress.setValue)
self.view.loadFinished.connect(self._load_finished)
layout.addWidget(self.view, 1)
if os.environ.get("WECOM_RPA_DISABLE_PORTAL") != "1":
self.view.setUrl(QUrl(CUSTOMER_SERVICE_URL))
else:
self.view.setHtml(
"
"
""
"AI 客服网页在测试模式下未加载
"
)
def _install_light_theme(self) -> None:
css_path = SCRIPT_DIR / "edge_light_theme" / "light-theme.css"
try:
css = css_path.read_text(encoding="utf-8")
except OSError:
css = "html,body,#app{background:#f8faf9!important;color:#17251f!important}"
source = f"""
(() => {{
const css = {json.dumps(css)};
const applyTheme = () => {{
let style = document.getElementById('zhen-qt-light-theme');
if (!style) {{
style = document.createElement('style');
style.id = 'zhen-qt-light-theme';
(document.head || document.documentElement).appendChild(style);
}}
style.textContent = css;
}};
applyTheme();
new MutationObserver(applyTheme).observe(document.documentElement, {{childList:true}});
}})();
"""
script = QWebEngineScript()
script.setName("zhen-light-theme")
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
script.setRunsOnSubFrames(True)
script.setSourceCode(source)
self.profile.scripts().insert(script)
def _load_started(self) -> None:
self.progress.setValue(2)
self.progress.show()
def _load_finished(self, _ok: bool) -> None:
self.progress.hide()
self.view.setFocus(Qt.OtherFocusReason)
def reload(self) -> None:
self.view.reload()
def open_external(self) -> None:
QDesktopServices.openUrl(self.view.url() or QUrl(CUSTOMER_SERVICE_URL))
class MetricCard(QFrame):
def __init__(self, label: str, value: str, meta: str):
super().__init__()
self.setObjectName("MetricCard")
layout = QVBoxLayout(self)
layout.setContentsMargins(19, 17, 19, 17)
layout.setSpacing(6)
layout.addWidget(_label(label, "MetricLabel"))
self.value = _label(value, "MetricValue")
layout.addWidget(self.value)
layout.addWidget(_label(meta, "MetricMeta"))
class DashboardPage(QScrollArea):
startRequested = Signal()
stopRequested = Signal()
pageRequested = Signal(int)
def __init__(self):
super().__init__()
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
root = QWidget()
root.setObjectName("PageRoot")
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(28, 25, 28, 28)
layout.setSpacing(18)
layout.addLayout(
_page_header(
"02",
"自动回复控制台",
"监控企业微信窗口、AI 决策和客户沉淀状态。",
)
)
hero = QFrame()
hero.setObjectName("HeroCard")
hero_layout = QHBoxLayout(hero)
hero_layout.setContentsMargins(25, 22, 25, 22)
hero_layout.setSpacing(20)
status_box = QVBoxLayout()
status_box.setSpacing(5)
status_box.addWidget(_label("WECOM LIVE MONITOR", "Eyebrow"))
self.status = _label("待命", "HeroStatus")
status_box.addWidget(self.status)
self.status_hint = _label("确认企业微信已登录后即可启动。", "HeroHint")
status_box.addWidget(self.status_hint)
self.timer = _label("运行时长 --:--:--", "CardSubtitle")
status_box.addWidget(self.timer)
hero_layout.addLayout(status_box, 1)
self.hero_button = _button("开始监听", "primary")
self.hero_button.setMinimumWidth(128)
self.hero_button.clicked.connect(self.startRequested)
hero_layout.addWidget(self.hero_button, 0, Qt.AlignVCenter)
layout.addWidget(hero)
metrics = QGridLayout()
metrics.setSpacing(12)
self.replied = MetricCard("本次已回复", "0", "自动回复数量")
self.false_pos = MetricCard("识别误判", "0", "已跳过的会话")
self.registration = MetricCard("待处理登记", "0", "挂号与回访线索")
self.sessions = MetricCard("会话档案", "0", "已保存上下文")
for column, card in enumerate(
(self.replied, self.false_pos, self.registration, self.sessions)
):
metrics.addWidget(card, 0, column)
metrics.setColumnStretch(column, 1)
layout.addLayout(metrics)
workflow, workflow_layout = _card(
"实际工作流",
"每一步都映射到当前软件已经具备的执行能力。",
)
steps = (
("A1", "消息监听", "未读识别 → 会话提取 → 自动回复", 2),
("A2", "AI 决策", "上下文记忆 → 医疗客服人格 → MCP 工具", 4),
("A3", "业务沉淀", "挂号登记 → 会话档案 → 运行记录", 3),
)
for code, title, text, page_index in steps:
row = QFrame()
row.setStyleSheet(
"QFrame{background:#F8FAF9;border:1px solid #E4ECE8;border-radius:11px;}"
)
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(15, 12, 15, 12)
badge = _label(code, "Eyebrow")
badge.setFixedWidth(38)
row_layout.addWidget(badge)
copy = QVBoxLayout()
copy.setSpacing(2)
copy.addWidget(_label(title, "CardTitle"))
copy.addWidget(_label(text, "CardSubtitle"))
row_layout.addLayout(copy, 1)
action = _button("打开")
action.clicked.connect(lambda _checked=False, i=page_index: self.pageRequested.emit(i))
row_layout.addWidget(action)
workflow_layout.addWidget(row)
layout.addWidget(workflow)
layout.addStretch(1)
def set_status(self, state: str, text: str, hint: str) -> None:
colors = {
"running": COLORS["success"],
"waiting": COLORS["warning"],
"connecting": COLORS["warning"],
"error": COLORS["danger"],
"stopping": COLORS["warning"],
"stopped": COLORS["ink"],
}
self.status.setText(text)
self.status.setStyleSheet(
f"color:{colors.get(state, COLORS['ink'])};font-size:30px;font-weight:700;"
)
self.status_hint.setText(hint)
active = state in {"running", "waiting", "connecting", "stopping"}
self.hero_button.setText("监听运行中" if active else "开始监听")
self.hero_button.setEnabled(not active)
class SettingsPage(QScrollArea):
saved = Signal(dict)
def __init__(self, settings: dict):
super().__init__()
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
root = QWidget()
root.setObjectName("PageRoot")
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(28, 25, 28, 28)
layout.setSpacing(18)
layout.addLayout(_page_header("03", "通用设置", "修改后自动保存,并在下次监听时生效。"))
runtime, runtime_layout = _card(
"运行参数", "参数直接参与企业微信监听,不是静态展示。"
)
runtime_grid = QGridLayout()
runtime_grid.setHorizontalSpacing(18)
runtime_grid.setVerticalSpacing(8)
runtime_grid.addWidget(_label("固定回复(关闭 AI 时使用)", "CardSubtitle"), 0, 0, 1, 2)
self.reply = QLineEdit(str(settings["auto_reply_text"]))
self.reply.setPlaceholderText("请输入固定回复")
runtime_grid.addWidget(self.reply, 1, 0, 1, 2)
runtime_grid.addWidget(_label("轮询间隔(秒)", "CardSubtitle"), 2, 0)
runtime_grid.addWidget(_label("鼠标静止等待(秒)", "CardSubtitle"), 2, 1)
self.poll = QDoubleSpinBox()
self.poll.setRange(0.2, 300.0)
self.poll.setDecimals(1)
self.poll.setValue(float(settings["poll_interval"]))
self.idle_seconds = QDoubleSpinBox()
self.idle_seconds.setRange(0.0, 3600.0)
self.idle_seconds.setDecimals(1)
self.idle_seconds.setValue(float(settings["mouse_idle_seconds"]))
runtime_grid.addWidget(self.poll, 3, 0)
runtime_grid.addWidget(self.idle_seconds, 3, 1)
runtime_grid.setColumnStretch(0, 1)
runtime_grid.setColumnStretch(1, 1)
runtime_layout.addLayout(runtime_grid)
self.save_status = _label("已加载保存配置", "SuccessText")
self.save_status.setAlignment(Qt.AlignRight)
runtime_layout.addWidget(self.save_status)
layout.addWidget(runtime)
safety, safety_layout = _card(
"人机共存",
"检测到用户正在操作时自动等待,避免与人工客服争抢鼠标。",
)
self.mouse_idle = QCheckBox("启用鼠标空闲检测")
self.mouse_idle.setChecked(bool(settings["mouse_idle_enabled"]))
safety_layout.addWidget(self.mouse_idle)
safety_layout.addWidget(
_label(
"企业微信未显示或最小化时,机器人会尝试还原到前台;本轮未就绪会暂停并自动重试。",
"CardSubtitle",
)
)
layout.addWidget(safety)
checks, checks_layout = _card("启动前检查")
for index, text in enumerate(
(
"企业微信 PC 端已登录,且账号状态正常。",
"如启用 AI,API 地址、密钥与模型配置已经保存。",
"双屏环境可以使用不同缩放比例,Qt 会跟随当前屏幕自动缩放。",
),
start=1,
):
row = QHBoxLayout()
badge = _label(f"{index:02d}", "Eyebrow")
badge.setFixedWidth(32)
row.addWidget(badge)
row.addWidget(_label(text, "CardSubtitle"), 1)
checks_layout.addLayout(row)
layout.addWidget(checks)
layout.addStretch(1)
self.save_timer = QTimer(self)
self.save_timer.setSingleShot(True)
self.save_timer.setInterval(500)
self.save_timer.timeout.connect(self._emit_save)
self.reply.textChanged.connect(self._schedule_save)
self.poll.valueChanged.connect(self._schedule_save)
self.idle_seconds.valueChanged.connect(self._schedule_save)
self.mouse_idle.toggled.connect(self._schedule_save)
def values(self) -> dict:
return {
"auto_reply_text": self.reply.text().strip() or "你好",
"poll_interval": self.poll.value(),
"mouse_idle_enabled": self.mouse_idle.isChecked(),
"mouse_idle_seconds": self.idle_seconds.value(),
}
def _schedule_save(self, *_args) -> None:
self.save_status.setText("正在保存…")
self.save_status.setObjectName("WarningText")
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
self.save_timer.start()
def _emit_save(self) -> None:
self.saved.emit(self.values())
def mark_saved(self, ok: bool, message: str) -> None:
self.save_status.setText(message)
self.save_status.setObjectName("SuccessText" if ok else "DangerText")
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
class SessionDetailDialog(QDialog):
def __init__(
self,
session_id: str,
history: list[dict],
last_lines: list[str],
parent: QWidget | None = None,
):
super().__init__(parent)
self.setObjectName("SessionDetailDialog")
self.setWindowTitle("客户会话记录")
self.setModal(True)
self.resize(920, 720)
self.setMinimumSize(700, 520)
self.session_id = session_id
self.history = history
self.last_lines = last_lines
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 22, 24, 20)
layout.setSpacing(16)
header = QHBoxLayout()
title_box = QVBoxLayout()
title_box.setSpacing(4)
title_box.addWidget(_label("客户会话记录", "PageTitle"))
title_box.addWidget(
_label(
f"会话 ID {session_id} · 共 {len(history)} 条消息",
"PageSubtitle",
)
)
header.addLayout(title_box, 1)
copy_button = _button("复制全部记录")
copy_button.clicked.connect(self._copy_all)
header.addWidget(copy_button, 0, Qt.AlignBottom)
layout.addLayout(header)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setStyleSheet(
"QScrollArea{background:#EEF3F0;border:1px solid #DDE7E1;border-radius:15px;}"
"QScrollArea>QWidget>QWidget{background:#EEF3F0;}"
)
message_root = QWidget()
message_layout = QVBoxLayout(message_root)
message_layout.setContentsMargins(20, 20, 20, 20)
message_layout.setSpacing(13)
scroll.setWidget(message_root)
if history:
for message in history:
self._add_message(message_layout, message)
elif last_lines:
self._add_message(
message_layout,
{
"role": "system",
"content": "仅保留了最近画面快照:\n\n" + "\n".join(last_lines),
"ts": None,
},
)
else:
empty = _label("这份会话档案暂时没有可显示的消息。", "PageSubtitle")
empty.setAlignment(Qt.AlignCenter)
message_layout.addWidget(empty)
message_layout.addStretch(1)
layout.addWidget(scroll, 1)
footer = QHBoxLayout()
footer.addWidget(_label("按时间顺序展示已保存的客户与客服消息。", "CardSubtitle"), 1)
close_button = _button("关闭")
close_button.clicked.connect(self.accept)
footer.addWidget(close_button)
layout.addLayout(footer)
QTimer.singleShot(0, lambda: scroll.verticalScrollBar().setValue(0))
@staticmethod
def _message_time(value) -> str:
try:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(float(value)))
except (TypeError, ValueError, OSError):
return ""
def _add_message(self, parent: QVBoxLayout, message: dict) -> None:
role = str(message.get("role") or "system").lower()
role_name = {"user": "客户", "assistant": "客服", "system": "系统记录"}.get(
role, role
)
bubble_name = {
"user": "CustomerBubble",
"assistant": "AssistantBubble",
}.get(role, "SystemBubble")
bubble = QFrame()
bubble.setObjectName(bubble_name)
bubble.setMaximumWidth(690)
bubble_layout = QVBoxLayout(bubble)
bubble_layout.setContentsMargins(15, 12, 15, 13)
bubble_layout.setSpacing(6)
meta = QHBoxLayout()
meta.setSpacing(10)
meta.addWidget(_label(role_name, "MessageRole"))
stamp = self._message_time(message.get("ts"))
if stamp:
meta.addWidget(_label(stamp, "MessageTime"))
meta.addStretch(1)
bubble_layout.addLayout(meta)
content = _label(str(message.get("content") or ""), "MessageContent")
content.setTextInteractionFlags(Qt.TextSelectableByMouse)
content.setMinimumWidth(260)
bubble_layout.addWidget(content)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
if role == "assistant":
row.addStretch(1)
row.addWidget(bubble)
elif role == "system":
row.addStretch(1)
row.addWidget(bubble)
row.addStretch(1)
else:
row.addWidget(bubble)
row.addStretch(1)
parent.addLayout(row)
def _copy_all(self) -> None:
lines = [f"客户会话记录\n会话 ID:{self.session_id}\n"]
role_names = {"user": "客户", "assistant": "客服", "system": "系统记录"}
if self.history:
for message in self.history:
role = str(message.get("role") or "system").lower()
name = role_names.get(role, role)
stamp = self._message_time(message.get("ts"))
heading = f"[{stamp}] {name}" if stamp else name
lines.append(f"{heading}\n{message.get('content') or ''}")
elif self.last_lines:
lines.append("系统记录\n" + "\n".join(self.last_lines))
QApplication.clipboard().setText("\n\n".join(lines))
class BusinessPage(QScrollArea):
logMessage = Signal(str, str)
def __init__(self):
super().__init__()
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
root = QWidget()
root.setObjectName("PageRoot")
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(28, 25, 28, 28)
layout.setSpacing(18)
header = QHBoxLayout()
header.addLayout(_page_header("04", "业务数据", "管理挂号线索与客户会话档案。"), 1)
refresh = _button("刷新数据")
refresh.clicked.connect(self.refresh_data)
header.addWidget(refresh, 0, Qt.AlignBottom)
layout.addLayout(header)
summary = QGridLayout()
summary.setSpacing(12)
self.registration_metric = MetricCard("待处理登记", "0", "需要人工跟进")
self.session_metric = MetricCard("会话档案", "0", "长期上下文记录")
summary.addWidget(self.registration_metric, 0, 0)
summary.addWidget(self.session_metric, 0, 1)
summary.setColumnStretch(0, 1)
summary.setColumnStretch(1, 1)
layout.addLayout(summary)
registrations, registrations_layout = _card(
"挂号与回访登记", "AI 识别出的明确挂号意向会沉淀在这里。"
)
self.registration_table = self._table(
["最近更新", "微信客户", "症状 / 诉求", "状态"]
)
registrations_layout.addWidget(self.registration_table)
registration_actions = QHBoxLayout()
registration_actions.addStretch(1)
done = _button("标记已联系", "primary")
done.clicked.connect(self._mark_registration_done)
delete = _button("删除所选", "danger")
delete.clicked.connect(self._delete_registrations)
registration_actions.addWidget(done)
registration_actions.addWidget(delete)
registrations_layout.addLayout(registration_actions)
layout.addWidget(registrations)
sessions, sessions_layout = _card(
"客户会话档案",
"机器人重启后仍可继续使用已保存的上下文;双击任意一行查看完整记录。",
)
self.session_table = self._table(["最近更新", "会话 ID", "消息数", "内容预览"])
self.session_table.setToolTip("双击任意会话查看完整记录")
self.session_table.cellDoubleClicked.connect(self._open_session_detail)
sessions_layout.addWidget(self.session_table)
session_actions = QHBoxLayout()
session_actions.addStretch(1)
view_session = _button("查看记录", "primary")
view_session.clicked.connect(self._open_selected_session)
delete_session = _button("删除所选", "danger")
delete_session.clicked.connect(self._delete_sessions)
session_actions.addWidget(view_session)
session_actions.addWidget(delete_session)
sessions_layout.addLayout(session_actions)
layout.addWidget(sessions)
layout.addStretch(1)
self.refresh_data()
@staticmethod
def _table(headers: list[str]) -> QTableWidget:
table = QTableWidget(0, len(headers))
table.setHorizontalHeaderLabels(headers)
table.setSelectionBehavior(QAbstractItemView.SelectRows)
table.setSelectionMode(QAbstractItemView.ExtendedSelection)
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
table.setAlternatingRowColors(True)
table.verticalHeader().hide()
table.verticalHeader().setDefaultSectionSize(42)
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
table.horizontalHeader().setStretchLastSection(True)
table.setMinimumHeight(230)
return table
@staticmethod
def _time_text(value) -> str:
try:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(float(value)))
except (TypeError, ValueError, OSError):
return "--"
@staticmethod
def _selected_ids(table: QTableWidget) -> list[str]:
rows = sorted({index.row() for index in table.selectionModel().selectedRows()})
ids = []
for row in rows:
item = table.item(row, 0)
if item:
ids.append(str(item.data(Qt.UserRole) or ""))
return [item for item in ids if item]
def refresh_data(self) -> None:
try:
from registration_store import RegistrationStore
self.registration_store = RegistrationStore()
leads = self.registration_store.list_leads(include_done=True)
except Exception as exc:
leads = []
self.logMessage.emit(f"读取挂号登记失败:{exc}", "err")
status_map = {"pending_symptom": "待补症状", "booked": "待联系", "done": "已联系"}
self.registration_table.setRowCount(len(leads))
for row, lead in enumerate(leads):
values = (
self._time_text(lead.get("updated")),
lead.get("contact") or "未知客户",
(lead.get("symptom") or "待补充").replace("\n", " "),
status_map.get(lead.get("status"), lead.get("status") or "--"),
)
for column, value in enumerate(values):
item = QTableWidgetItem(str(value))
if column == 0:
item.setData(Qt.UserRole, lead.get("id"))
self.registration_table.setItem(row, column, item)
pending = sum(1 for item in leads if item.get("status") in {"pending_symptom", "booked"})
self.registration_metric.value.setText(str(pending))
try:
from conversation_store import ConversationStore
self.conversation_store = ConversationStore(str(SCRIPT_DIR / "conversations.json"))
sessions = self.conversation_store.list_sessions(limit=500)
except Exception as exc:
sessions = []
self.logMessage.emit(f"读取会话档案失败:{exc}", "err")
self.session_table.setRowCount(len(sessions))
for row, session in enumerate(sessions):
values = (
self._time_text(session.get("updated")),
session.get("session_id") or "--",
session.get("message_count") or 0,
session.get("preview") or "--",
)
for column, value in enumerate(values):
item = QTableWidgetItem(str(value))
if column == 0:
item.setData(Qt.UserRole, session.get("session_id"))
self.session_table.setItem(row, column, item)
self.session_metric.value.setText(str(len(sessions)))
def _mark_registration_done(self) -> None:
ids = self._selected_ids(self.registration_table)
if not ids:
QMessageBox.information(self, "提示", "请先选择登记记录。")
return
count = self.registration_store.set_status_many(ids, "done")
self.logMessage.emit(f"已将 {count} 条登记标记为已联系", "ok")
self.refresh_data()
def _delete_registrations(self) -> None:
ids = self._selected_ids(self.registration_table)
if not ids:
QMessageBox.information(self, "提示", "请先选择要删除的登记。")
return
if QMessageBox.question(self, "确认删除", f"确定删除选中的 {len(ids)} 条登记吗?") != QMessageBox.Yes:
return
count = self.registration_store.delete_many(ids)
self.logMessage.emit(f"已删除 {count} 条挂号登记", "warn")
self.refresh_data()
def _delete_sessions(self) -> None:
ids = self._selected_ids(self.session_table)
if not ids:
QMessageBox.information(self, "提示", "请先选择要删除的会话档案。")
return
if QMessageBox.question(self, "确认删除", f"确定删除选中的 {len(ids)} 份档案吗?") != QMessageBox.Yes:
return
count = sum(1 for session_id in ids if self.conversation_store.delete(session_id))
self.logMessage.emit(f"已删除 {count} 份会话档案", "warn")
self.refresh_data()
def _open_selected_session(self) -> None:
rows = self.session_table.selectionModel().selectedRows()
if not rows:
QMessageBox.information(self, "提示", "请先选择要查看的会话档案。")
return
self._open_session_detail(rows[0].row(), 0)
def _open_session_detail(self, row: int, _column: int) -> None:
id_item = self.session_table.item(row, 0)
session_id = str(id_item.data(Qt.UserRole) or "") if id_item else ""
if not session_id:
QMessageBox.warning(self, "无法打开", "未找到这条会话的 ID。")
return
try:
history = list(self.conversation_store.history(session_id))
last_lines = list(self.conversation_store.last_lines(session_id))
except Exception as exc:
QMessageBox.critical(self, "无法读取会话", str(exc))
return
SessionDetailDialog(session_id, history, last_lines, self).exec()
class BackendLoginDialog(QDialog):
completed = Signal(bool, str)
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("登录配置后台")
self.setObjectName("SessionDetailDialog")
self.setModal(True)
self.setMinimumWidth(480)
layout = QVBoxLayout(self)
layout.setContentsMargins(26, 24, 26, 24)
layout.setSpacing(14)
layout.addWidget(_label("登录配置后台", "CardTitle"))
layout.addWidget(
_label("登录成功后,模型与 MCP 参数会自动同步到当前电脑。", "CardSubtitle")
)
import backend_client
settings = backend_client.load_settings()
layout.addWidget(_label("后台地址", "CardSubtitle"))
self.server_url = QLineEdit(settings.get("server_url", backend_client.DEFAULT_SERVER_URL))
self.server_url.setPlaceholderText("http://127.0.0.1:8765")
layout.addWidget(self.server_url)
layout.addWidget(_label("用户名", "CardSubtitle"))
self.username = QLineEdit(settings.get("username", ""))
self.username.setPlaceholderText("请输入后台用户名")
layout.addWidget(self.username)
layout.addWidget(_label("密码", "CardSubtitle"))
self.password = QLineEdit()
self.password.setEchoMode(QLineEdit.Password)
self.password.setPlaceholderText("密码不会保存在本地")
self.password.returnPressed.connect(self.submit)
layout.addWidget(self.password)
self.auto_sync = QCheckBox("启动及运行期间自动同步")
self.auto_sync.setChecked(bool(settings.get("auto_sync", True)))
layout.addWidget(self.auto_sync)
self.status = _label("", "DangerText")
self.status.setWordWrap(True)
layout.addWidget(self.status)
actions = QHBoxLayout()
actions.addStretch(1)
cancel = _button("取消")
cancel.clicked.connect(self.reject)
actions.addWidget(cancel)
self.login_button = _button("登录并同步", "primary")
self.login_button.clicked.connect(self.submit)
actions.addWidget(self.login_button)
layout.addLayout(actions)
self.completed.connect(self._finished)
def submit(self) -> None:
if not self.server_url.text().strip() or not self.username.text().strip() or not self.password.text():
self.status.setText("请填写后台地址、用户名和密码")
return
self.login_button.setEnabled(False)
self.status.setObjectName("WarningText")
self.status.setText("正在登录并获取模型配置…")
self.status.style().unpolish(self.status)
self.status.style().polish(self.status)
server_url = self.server_url.text().strip()
username = self.username.text().strip()
password = self.password.text()
auto_sync = self.auto_sync.isChecked()
def worker() -> None:
try:
import backend_client
backend_client.login(server_url, username, password, auto_sync=auto_sync)
result = backend_client.sync_config(force=True)
message = str(result.get("message") or "登录并同步成功")
except Exception as exc:
self.completed.emit(False, str(exc))
else:
self.completed.emit(True, message)
threading.Thread(target=worker, daemon=True).start()
def _finished(self, ok: bool, message: str) -> None:
self.login_button.setEnabled(True)
self.status.setObjectName("SuccessText" if ok else "DangerText")
self.status.setText(message)
self.status.style().unpolish(self.status)
self.status.style().polish(self.status)
if ok:
QTimer.singleShot(250, self.accept)
class PersonaPage(QScrollArea):
saved = Signal(bool, str)
backendSyncFinished = Signal(bool, str)
def __init__(self):
super().__init__()
self.setObjectName("PageScroll")
self.setWidgetResizable(True)
self.setFrameShape(QFrame.NoFrame)
root = QWidget()
root.setObjectName("PageRoot")
self.setWidget(root)
layout = QVBoxLayout(root)
layout.setContentsMargins(28, 25, 28, 28)
layout.setSpacing(18)
layout.addLayout(_page_header("05", "AI 人格与能力", "统一管理模型、客服身份、上下文和 MCP 工具。"))
import ai_config
remote, remote_layout = _card(
"后台同步",
"在管理后台配置模型后,本机启动及运行期间会自动获取并应用。",
)
remote_row = QHBoxLayout()
remote_copy = QVBoxLayout()
self.remote_status = _label("正在读取后台连接…", "WarningText")
self.remote_meta = _label("", "CardSubtitle")
remote_copy.addWidget(self.remote_status)
remote_copy.addWidget(self.remote_meta)
remote_row.addLayout(remote_copy, 1)
self.logout_backend_button = _button("退出后台")
self.logout_backend_button.clicked.connect(self.logout_backend)
remote_row.addWidget(self.logout_backend_button)
self.manage_backend_button = _button("打开管理后台")
self.manage_backend_button.clicked.connect(self.open_backend_page)
remote_row.addWidget(self.manage_backend_button)
self.sync_backend_button = _button("立即同步")
self.sync_backend_button.clicked.connect(lambda: self.sync_from_backend(silent=False))
remote_row.addWidget(self.sync_backend_button)
self.login_backend_button = _button("登录后台", "primary")
self.login_backend_button.clicked.connect(self.open_backend_login)
remote_row.addWidget(self.login_backend_button)
remote_layout.addLayout(remote_row)
layout.addWidget(remote)
overview, overview_layout = _card("能力开关", "关闭 AI 后将使用通用设置中的固定回复。")
switch_grid = QGridLayout()
self.ai_enabled = QCheckBox("启用 AI 回复")
self.ai_enabled.setChecked(bool(ai_config.AI_ENABLED))
self.context_enabled = QCheckBox("启用会话上下文")
self.context_enabled.setChecked(bool(ai_config.AI_CONTEXT_ENABLED))
self.counter_enabled = QCheckBox("启用反辱骂策略")
self.counter_enabled.setChecked(bool(ai_config.AI_COUNTER_INSULT_ENABLED))
self.mcp_enabled = QCheckBox("启用 MCP 工具")
self.mcp_enabled.setChecked(bool(ai_config.AI_MCP_ENABLED))
for index, switch in enumerate(
(self.ai_enabled, self.context_enabled, self.counter_enabled, self.mcp_enabled)
):
switch_grid.addWidget(switch, index // 2, index % 2)
overview_layout.addLayout(switch_grid)
layout.addWidget(overview)
identity, identity_layout = _card("模型与身份", "保存后立即写入 ai_settings.json。")
form = QGridLayout()
form.setHorizontalSpacing(18)
form.setVerticalSpacing(8)
self.api_base = self._field(form, 0, 0, "API 地址", ai_config.AI_API_BASE)
self.model = self._field(form, 0, 1, "模型名称", ai_config.AI_MODEL)
self.api_key = self._field(form, 2, 0, "API Key", ai_config.AI_API_KEY)
self.api_key.setEchoMode(QLineEdit.Password)
self.agent_name = self._field(form, 2, 1, "客服名称", ai_config.AI_AGENT_NAME)
self.hospital = self._field(form, 4, 0, "机构名称", ai_config.AI_HOSPITAL_NAME, 1, 2)
self.rounds = QSpinBox()
self.rounds.setRange(1, 50)
self.rounds.setValue(int(ai_config.AI_CONTEXT_MAX_ROUNDS))
self.max_tokens = QSpinBox()
self.max_tokens.setRange(50, 32000)
self.max_tokens.setValue(int(ai_config.AI_MAX_TOKENS))
self.temperature = QDoubleSpinBox()
self.temperature.setRange(0.0, 2.0)
self.temperature.setSingleStep(0.05)
self.temperature.setValue(float(ai_config.AI_TEMPERATURE))
form.addWidget(_label("上下文轮数", "CardSubtitle"), 6, 0)
form.addWidget(_label("最大回复 tokens", "CardSubtitle"), 6, 1)
form.addWidget(self.rounds, 7, 0)
form.addWidget(self.max_tokens, 7, 1)
form.addWidget(_label("温度", "CardSubtitle"), 8, 0)
form.addWidget(self.temperature, 9, 0)
form.setColumnStretch(0, 1)
form.setColumnStretch(1, 1)
identity_layout.addLayout(form)
layout.addWidget(identity)
mcp, mcp_layout = _card("MCP 服务器", "填写服务器 JSON 数组,配置将与 AI 能力一起保存。")
self.mcp_json = QPlainTextEdit()
self.mcp_json.setMinimumHeight(160)
self.mcp_json.setPlainText(
json.dumps(getattr(ai_config, "AI_MCP_SERVERS", []) or [], ensure_ascii=False, indent=2)
)
mcp_layout.addWidget(self.mcp_json)
mcp_row = QHBoxLayout()
mcp_row.addWidget(_label("单次回复最多工具轮数", "CardSubtitle"))
self.mcp_rounds = QSpinBox()
self.mcp_rounds.setRange(1, 20)
self.mcp_rounds.setValue(int(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5)))
mcp_row.addWidget(self.mcp_rounds)
mcp_row.addStretch(1)
mcp_layout.addLayout(mcp_row)
layout.addWidget(mcp)
actions = QHBoxLayout()
self.save_status = _label("", "SuccessText")
actions.addWidget(self.save_status, 1)
self.save_button = _button("保存 AI 配置", "primary")
self.save_button.clicked.connect(self.save_config)
actions.addWidget(self.save_button)
layout.addLayout(actions)
layout.addStretch(1)
self._backend_sync_running = False
self._backend_sync_silent = False
self.backendSyncFinished.connect(self._backend_sync_finished)
self._managed_widgets = (
self.ai_enabled,
self.context_enabled,
self.counter_enabled,
self.mcp_enabled,
self.api_base,
self.model,
self.api_key,
self.agent_name,
self.hospital,
self.rounds,
self.max_tokens,
self.temperature,
self.mcp_json,
self.mcp_rounds,
self.save_button,
)
self.refresh_backend_status()
@staticmethod
def _field(
layout: QGridLayout,
row: int,
column: int,
title: str,
value,
row_span: int = 1,
column_span: int = 1,
) -> QLineEdit:
layout.addWidget(_label(title, "CardSubtitle"), row, column, 1, column_span)
field = QLineEdit(str(value or ""))
layout.addWidget(field, row + 1, column, row_span, column_span)
return field
def save_config(self) -> None:
try:
import backend_client
remote_managed = backend_client.is_configured() and bool(
backend_client.load_settings().get("auto_sync", True)
)
except Exception:
remote_managed = False
if remote_managed:
self.save_status.setText("当前由后台统一管理,请在后台修改后点击“立即同步”")
self.save_status.setObjectName("WarningText")
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
return
try:
servers = json.loads(self.mcp_json.toPlainText().strip() or "[]")
if not isinstance(servers, list):
raise ValueError("MCP JSON 根节点必须是数组")
import ai_config
ai_config.AI_ENABLED = self.ai_enabled.isChecked()
ai_config.AI_CONTEXT_ENABLED = self.context_enabled.isChecked()
ai_config.AI_COUNTER_INSULT_ENABLED = self.counter_enabled.isChecked()
ai_config.AI_MCP_ENABLED = self.mcp_enabled.isChecked()
ai_config.AI_API_BASE = self.api_base.text().strip()
ai_config.AI_API_KEY = self.api_key.text().strip()
ai_config.AI_MODEL = self.model.text().strip()
ai_config.AI_AGENT_NAME = self.agent_name.text().strip()
ai_config.AI_HOSPITAL_NAME = self.hospital.text().strip()
ai_config.AI_CONTEXT_MAX_ROUNDS = self.rounds.value()
ai_config.AI_MAX_TOKENS = self.max_tokens.value()
ai_config.AI_TEMPERATURE = self.temperature.value()
ai_config.AI_MCP_SERVERS = servers
ai_config.AI_MCP_MAX_ROUNDS = self.mcp_rounds.value()
ai_config.AI_SYSTEM_PROMPT = ai_config.build_system_prompt()
ai_config.save_settings()
except Exception as exc:
self.save_status.setText(f"保存失败:{exc}")
self.save_status.setObjectName("DangerText")
self.saved.emit(False, str(exc))
else:
self.save_status.setText("配置已保存并立即生效")
self.save_status.setObjectName("SuccessText")
self.saved.emit(True, "AI 配置已保存并生效")
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
def open_backend_login(self) -> None:
dialog = BackendLoginDialog(self)
if dialog.exec() == QDialog.Accepted:
self.reload_from_ai_config()
self.refresh_backend_status()
self.saved.emit(True, "后台已登录,模型配置已自动同步")
def open_backend_page(self) -> None:
try:
import backend_client
url = backend_client.load_settings().get(
"server_url", backend_client.DEFAULT_SERVER_URL
)
QDesktopServices.openUrl(QUrl(str(url)))
except Exception as exc:
self.saved.emit(False, f"无法打开管理后台:{exc}")
def logout_backend(self) -> None:
answer = QMessageBox.question(
self,
"退出配置后台",
"退出后将停止自动同步,但已下发到本机的配置会继续保留。确定退出吗?",
)
if answer != QMessageBox.Yes:
return
try:
import backend_client
backend_client.logout()
except Exception as exc:
self.saved.emit(False, f"退出后台失败:{exc}")
return
self.refresh_backend_status()
self.saved.emit(True, "已退出配置后台,当前配置保留在本机")
def refresh_backend_status(self) -> None:
try:
import backend_client
summary = backend_client.connection_summary()
except Exception as exc:
self.remote_status.setObjectName("DangerText")
self.remote_status.setText(f"后台连接配置读取失败:{exc}")
return
configured = bool(summary["configured"])
authenticated = bool(summary.get("authenticated"))
managed = configured and bool(summary["auto_sync"])
if configured:
last_error = str(summary.get("last_error") or "")
self.remote_status.setObjectName("DangerText" if last_error else "SuccessText")
self.remote_status.setText(
f"最近同步失败 · {last_error}"
if last_error
else (
f"已连接 · {summary['username']} · 配置 v{summary['last_version']}"
if authenticated
else f"已发现本机后台 · 配置 v{summary['last_version']}"
)
)
sync_at = summary.get("last_sync_at") or "尚未同步"
self.remote_meta.setText(f"{summary['server_url']} · 最后同步 {sync_at}")
else:
self.remote_status.setObjectName("WarningText")
self.remote_status.setText("尚未登录配置后台")
self.remote_meta.setText(f"默认地址 {summary['server_url']} · 当前使用本机配置")
self.remote_status.style().unpolish(self.remote_status)
self.remote_status.style().polish(self.remote_status)
self.login_backend_button.setVisible(not authenticated)
self.login_backend_button.setText("账号登录" if configured else "登录后台")
self.logout_backend_button.setVisible(authenticated)
self.sync_backend_button.setVisible(configured)
for widget in self._managed_widgets:
widget.setEnabled(not managed)
self.save_status.setText("后台统一管理中,本地字段为只读" if managed else "")
self.save_status.setObjectName("SuccessText" if managed else "CardSubtitle")
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
def sync_from_backend(self, *, silent: bool = True) -> None:
if self._backend_sync_running:
return
try:
import backend_client
if not backend_client.is_configured():
return
except Exception as exc:
if not silent:
self.saved.emit(False, f"后台连接配置读取失败:{exc}")
return
self._backend_sync_running = True
self._backend_sync_silent = silent
self.sync_backend_button.setEnabled(False)
self.remote_status.setObjectName("WarningText")
self.remote_status.setText("正在从后台同步模型配置…")
self.remote_status.style().unpolish(self.remote_status)
self.remote_status.style().polish(self.remote_status)
def worker() -> None:
try:
result = backend_client.sync_config(force=not silent)
message = str(result.get("message") or "后台配置同步完成")
except Exception as exc:
self.backendSyncFinished.emit(False, str(exc))
else:
self.backendSyncFinished.emit(True, message)
threading.Thread(target=worker, daemon=True).start()
def _backend_sync_finished(self, ok: bool, message: str) -> None:
silent = self._backend_sync_silent
self._backend_sync_running = False
self.sync_backend_button.setEnabled(True)
if ok:
self.reload_from_ai_config()
self.refresh_backend_status()
if not silent or not ok:
self.saved.emit(ok, message if ok else f"后台同步失败:{message}")
def reload_from_ai_config(self) -> None:
import ai_config
self.ai_enabled.setChecked(bool(ai_config.AI_ENABLED))
self.context_enabled.setChecked(bool(ai_config.AI_CONTEXT_ENABLED))
self.counter_enabled.setChecked(bool(ai_config.AI_COUNTER_INSULT_ENABLED))
self.mcp_enabled.setChecked(bool(ai_config.AI_MCP_ENABLED))
self.api_base.setText(str(ai_config.AI_API_BASE or ""))
self.model.setText(str(ai_config.AI_MODEL or ""))
self.api_key.setText(str(ai_config.AI_API_KEY or ""))
self.agent_name.setText(str(ai_config.AI_AGENT_NAME or ""))
self.hospital.setText(str(ai_config.AI_HOSPITAL_NAME or ""))
self.rounds.setValue(int(ai_config.AI_CONTEXT_MAX_ROUNDS))
self.max_tokens.setValue(int(ai_config.AI_MAX_TOKENS))
self.temperature.setValue(float(ai_config.AI_TEMPERATURE))
self.mcp_json.setPlainText(
json.dumps(getattr(ai_config, "AI_MCP_SERVERS", []) or [], ensure_ascii=False, indent=2)
)
self.mcp_rounds.setValue(int(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5)))
class LogPage(QWidget):
def __init__(self):
super().__init__()
self.setObjectName("PageRoot")
layout = QVBoxLayout(self)
layout.setContentsMargins(28, 25, 28, 28)
layout.setSpacing(18)
header = QHBoxLayout()
header.addLayout(_page_header("06", "运行日志", "实时查看连接、识别、AI 与业务沉淀事件。"), 1)
clear = _button("清空日志")
clear.clicked.connect(self.clear)
header.addWidget(clear, 0, Qt.AlignBottom)
layout.addLayout(header)
card, card_layout = _card()
self.editor = QTextEdit()
self.editor.setReadOnly(True)
self.editor.setAcceptRichText(True)
self.editor.setStyleSheet(
"QTextEdit{font-family:'Cascadia Mono','Microsoft YaHei UI';font-size:13px;line-height:1.5;}"
)
card_layout.addWidget(self.editor)
layout.addWidget(card, 1)
def append(self, message: str, tag: str = "") -> None:
colors = {
"ok": COLORS["success"],
"warn": COLORS["warning"],
"err": COLORS["danger"],
"notify": COLORS["accent_dark"],
}
color = colors.get(tag, COLORS["ink"])
stamp = time.strftime("%H:%M:%S")
safe = (
str(message)
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\n", "
")
)
self.editor.append(
f'[{stamp}] '
f'{safe}'
)
def clear(self) -> None:
self.editor.clear()
class CapsuleWindow(QWidget):
expandRequested = Signal()
stopRequested = Signal()
def __init__(self):
super().__init__(None)
self.setWindowFlags(
Qt.Tool
| Qt.FramelessWindowHint
| Qt.WindowStaysOnTopHint
| Qt.WindowDoesNotAcceptFocus
)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.setFixedSize(430, 82)
self._drag_origin: QPoint | None = None
self._window_origin = QPoint()
outer = QVBoxLayout(self)
outer.setContentsMargins(12, 8, 12, 14)
panel = QFrame()
panel.setObjectName("CapsulePanel")
panel.setStyleSheet(
"QFrame#CapsulePanel{background:#FFFFFF;border:1px solid #CFE0D8;border-radius:16px;}"
"QFrame#CapsulePanel QLabel{background:transparent;border:none;}"
)
_shadow(panel, 28, 7, 38)
row = QHBoxLayout(panel)
row.setContentsMargins(17, 10, 10, 10)
row.setSpacing(11)
grip = QLabel("•••")
grip.setAlignment(Qt.AlignCenter)
grip.setFixedSize(38, 38)
grip.setStyleSheet(
"QLabel{background:#F0F6F3;border:1px solid #D8E6DF;border-radius:19px;"
"color:#789087;font-family:'Cascadia Mono';font-weight:700;}"
)
row.addWidget(grip)
self.dot = QLabel("●")
self.dot.setStyleSheet("QLabel{color:#10A57A;font-size:17px;}")
row.addWidget(self.dot)
copy = QVBoxLayout()
copy.setSpacing(1)
self.status = QLabel("监听中")
self.status.setStyleSheet(
"QLabel{color:#087A59;font-size:14px;font-weight:700;}"
)
self.hint = QLabel("安全运行中")
self.hint.setStyleSheet("QLabel{color:#7B8D84;font-size:11px;}")
copy.addWidget(self.status)
copy.addWidget(self.hint)
row.addLayout(copy, 1)
self.timer = QLabel("00:00:00")
self.timer.setAlignment(Qt.AlignCenter)
self.timer.setMinimumWidth(82)
self.timer.setStyleSheet(
"QLabel{background:#F2F7F4;border:1px solid #DCE8E2;border-radius:9px;"
"padding:7px 9px;font-family:'Cascadia Mono';font-size:12px;color:#30473D;}"
)
row.addWidget(self.timer)
expand = QPushButton("↗")
expand.setToolTip("展开控制台")
expand.setCursor(Qt.PointingHandCursor)
expand.setFixedSize(34, 34)
expand.setStyleSheet(
"QPushButton{background:#E7F4EE;color:#087A59;border:1px solid #CFE8DD;"
"border-radius:8px;font-size:17px;font-weight:700;}"
"QPushButton:hover{background:#D4EFE4;border-color:#AEDCC9;}"
"QPushButton:pressed{background:#C5E8D9;}"
)
expand.clicked.connect(self.expandRequested)
row.addWidget(expand)
stop = QPushButton("×")
stop.setToolTip("停止监听")
stop.setCursor(Qt.PointingHandCursor)
stop.setFixedSize(34, 34)
stop.setStyleSheet(
"QPushButton{background:#FBEAEC;color:#C74452;border:1px solid #F2CDD2;"
"border-radius:8px;font-size:18px;}"
"QPushButton:hover{background:#D85260;color:#FFFFFF;border-color:#D85260;}"
)
stop.clicked.connect(self.stopRequested)
row.addWidget(stop)
outer.addWidget(panel)
def show_near(self, window: QWidget) -> None:
screen = window.screen() or QApplication.primaryScreen()
area = screen.availableGeometry()
x = area.x() + (area.width() - self.width()) // 2
y = area.y() + 28
self.move(x, y)
self.show()
def mousePressEvent(self, event) -> None:
if event.button() == Qt.LeftButton:
self._drag_origin = event.globalPosition().toPoint()
self._window_origin = self.pos()
event.accept()
def mouseMoveEvent(self, event) -> None:
if self._drag_origin is not None and event.buttons() & Qt.LeftButton:
self.move(self._window_origin + event.globalPosition().toPoint() - self._drag_origin)
event.accept()
def mouseReleaseEvent(self, event) -> None:
self._drag_origin = None
super().mouseReleaseEvent(event)
def set_status(self, state: str, text: str) -> None:
colors = {
"running": "#10A57A",
"waiting": "#B67820",
"connecting": "#B67820",
"stopping": "#B67820",
"error": "#D85260",
}
hints = {
"running": "安全运行中",
"waiting": "等待企业微信",
"connecting": "正在连接窗口",
"stopping": "正在结束任务",
"error": "请查看运行日志",
"stopped": "监听已停止",
}
color = colors.get(state, "#7B8D84")
self.dot.setStyleSheet(f"QLabel{{color:{color};font-size:17px;}}")
self.status.setText(text)
self.status.setStyleSheet(
f"QLabel{{color:{color};font-size:14px;font-weight:700;}}"
)
self.hint.setText(hints.get(state, text))
class Sidebar(QFrame):
pageRequested = Signal(int)
startRequested = Signal()
stopRequested = Signal()
PAGE_NAMES = ("AI 客服", "自动回复", "通用设置", "业务数据", "AI 人格", "运行日志")
def __init__(self):
super().__init__()
self.setObjectName("Sidebar")
self.setFixedWidth(258)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 24, 18, 18)
layout.setSpacing(8)
layout.addWidget(_label("ZHEN AI DESK", "Brand"))
layout.addWidget(_label("AI 客服 · 企业微信自动回复", "BrandSub"))
layout.addSpacing(13)
status_card = QFrame()
status_card.setObjectName("SideStatus")
status_layout = QVBoxLayout(status_card)
status_layout.setContentsMargins(15, 13, 15, 13)
status_layout.setSpacing(3)
self.status_title = _label("● 已停止", "SideStatusTitle")
self.status_hint = _label("等待连接企业微信", "SideStatusHint")
status_layout.addWidget(self.status_title)
status_layout.addWidget(self.status_hint)
layout.addWidget(status_card)
layout.addSpacing(9)
layout.addWidget(_label("CONTROL CENTER", "SectionLabel"))
self.nav_buttons: list[QPushButton] = []
for index, name in enumerate(self.PAGE_NAMES, start=1):
button = QPushButton(f"{index:02d} {name}")
button.setObjectName("NavButton")
button.setCheckable(True)
button.setAutoExclusive(True)
button.setCursor(Qt.PointingHandCursor)
button.clicked.connect(lambda _checked=False, i=index - 1: self.pageRequested.emit(i))
layout.addWidget(button)
self.nav_buttons.append(button)
self.nav_buttons[0].setChecked(True)
layout.addStretch(1)
self.monitor_button = _button("开始监听", "side")
self.monitor_button.clicked.connect(self.startRequested)
layout.addWidget(self.monitor_button)
layout.addWidget(_label("RPA DESKTOP / LOCAL", "SectionLabel"))
def select(self, index: int) -> None:
if 0 <= index < len(self.nav_buttons):
self.nav_buttons[index].setChecked(True)
def set_status(self, state: str, text: str, hint: str) -> None:
colors = {
"running": "#43E1AE",
"waiting": "#F2BC64",
"connecting": "#F2BC64",
"stopping": "#F2BC64",
"error": "#FF818E",
"stopped": "#A5B9B0",
}
self.status_title.setText(f"● {text}")
self.status_title.setStyleSheet(
f"color:{colors.get(state, '#A5B9B0')};font-size:15px;font-weight:700;"
)
self.status_hint.setText(hint)
active = state in {"running", "waiting", "connecting", "stopping"}
self.monitor_button.setText("停止监听" if active else "开始监听")
try:
self.monitor_button.clicked.disconnect()
except RuntimeError:
pass
self.monitor_button.clicked.connect(self.stopRequested if active else self.startRequested)
self.monitor_button.setObjectName("DangerButton" if active else "SidePrimary")
self.monitor_button.style().unpolish(self.monitor_button)
self.monitor_button.style().polish(self.monitor_button)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(WIN_TITLE)
self.setMinimumSize(1120, 720)
self._set_initial_geometry()
self._queue: queue.Queue = queue.Queue()
self._thread = None
self._running = False
self._start_time: float | None = None
self._status_key = "stopped"
self._original_stdout = sys.stdout
self._stdout_proxy = None
self._saved_geometry = None
self._was_maximized = False
self.runtime_settings = self._load_runtime_settings()
root = QWidget()
root.setObjectName("AppRoot")
self.setCentralWidget(root)
root_layout = QHBoxLayout(root)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
self.sidebar = Sidebar()
root_layout.addWidget(self.sidebar)
self.stack = FadingStack()
root_layout.addWidget(self.stack, 1)
self.portal_page = PortalPage()
self.dashboard_page = DashboardPage()
self.settings_page = SettingsPage(self.runtime_settings)
self.business_page = BusinessPage()
self.persona_page = PersonaPage()
self.log_page = LogPage()
for page in (
self.portal_page,
self.dashboard_page,
self.settings_page,
self.business_page,
self.persona_page,
self.log_page,
):
self.stack.addWidget(page)
self.capsule = CapsuleWindow()
self._connect_signals()
self.business_refresh_timer = QTimer(self)
self.business_refresh_timer.setSingleShot(True)
self.business_refresh_timer.setInterval(220)
self.business_refresh_timer.timeout.connect(self._refresh_counts)
self.set_status("stopped", "已停止")
self._refresh_counts()
self.append_log("新控制台已就绪。请确认企业微信已登录,再开始监听。", "notify")
self.queue_timer = QTimer(self)
self.queue_timer.setInterval(140)
self.queue_timer.timeout.connect(self._process_queue)
self.queue_timer.start()
try:
import backend_client
sync_seconds = int(
backend_client.load_settings().get("sync_interval_seconds", 300)
)
except Exception:
sync_seconds = 300
self.backend_sync_timer = QTimer(self)
self.backend_sync_timer.setInterval(max(60, sync_seconds) * 1000)
self.backend_sync_timer.timeout.connect(
lambda: self.persona_page.sync_from_backend(silent=True)
)
self.backend_sync_timer.start()
QTimer.singleShot(350, lambda: self.persona_page.sync_from_backend(silent=True))
def _set_initial_geometry(self) -> None:
screen = QApplication.primaryScreen()
area = screen.availableGeometry() if screen else QRect(0, 0, 1440, 900)
width = min(1480, max(1120, area.width() - 72))
height = min(940, max(720, area.height() - 72))
self.resize(width, height)
self.move(area.x() + (area.width() - width) // 2, area.y() + (area.height() - height) // 2)
def _connect_signals(self) -> None:
self.sidebar.pageRequested.connect(self.show_page)
self.sidebar.startRequested.connect(self.start_monitoring)
self.sidebar.stopRequested.connect(self.stop_monitoring)
self.dashboard_page.startRequested.connect(self.start_monitoring)
self.dashboard_page.stopRequested.connect(self.stop_monitoring)
self.dashboard_page.pageRequested.connect(self.show_page)
self.settings_page.saved.connect(self.save_runtime_settings)
self.business_page.logMessage.connect(self.append_log)
self.persona_page.saved.connect(self._persona_saved)
self.capsule.expandRequested.connect(self.expand_console)
self.capsule.stopRequested.connect(self.stop_monitoring)
@staticmethod
def _runtime_defaults() -> dict:
return {
"auto_reply_text": "你好",
"poll_interval": 2.0,
"mouse_idle_enabled": True,
"mouse_idle_seconds": 20.0,
}
def _load_runtime_settings(self) -> dict:
settings = self._runtime_defaults()
try:
saved = json.loads(APP_SETTINGS_FILE.read_text(encoding="utf-8"))
if isinstance(saved, dict):
settings.update({key: saved[key] for key in settings if key in saved})
except (OSError, ValueError, TypeError):
pass
try:
settings["poll_interval"] = max(0.2, float(settings["poll_interval"]))
settings["mouse_idle_seconds"] = max(0.0, float(settings["mouse_idle_seconds"]))
settings["mouse_idle_enabled"] = bool(settings["mouse_idle_enabled"])
settings["auto_reply_text"] = str(settings["auto_reply_text"]).strip() or "你好"
except (TypeError, ValueError):
return self._runtime_defaults()
return settings
def save_runtime_settings(self, settings: dict | None = None) -> None:
self.runtime_settings = dict(settings or self.settings_page.values())
try:
temporary = APP_SETTINGS_FILE.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(self.runtime_settings, ensure_ascii=False, indent=2),
encoding="utf-8",
)
os.replace(temporary, APP_SETTINGS_FILE)
except OSError as exc:
self.settings_page.mark_saved(False, f"保存失败:{exc}")
self.append_log(f"运行参数保存失败:{exc}", "err")
else:
self.settings_page.mark_saved(True, "已自动保存")
def show_page(self, index: int) -> None:
self.sidebar.select(index)
self.stack.setCurrentIndexAnimated(index)
if index == 3:
self.business_page.refresh_data()
if index == 0:
QTimer.singleShot(80, lambda: self.portal_page.view.setFocus(Qt.OtherFocusReason))
def start_monitoring(self) -> None:
if self._thread is not None and self._thread.is_alive():
self.append_log("监听线程已经在运行", "warn")
return
self.save_runtime_settings()
self.persona_page.save_config()
self._stdout_proxy = LogQueue(self._queue)
sys.stdout = self._stdout_proxy
self._thread = BotThread(
self._queue,
self.runtime_settings["auto_reply_text"],
float(self.runtime_settings["poll_interval"]),
mouse_idle_enabled=bool(self.runtime_settings["mouse_idle_enabled"]),
mouse_idle_seconds=float(self.runtime_settings["mouse_idle_seconds"]),
)
self._running = True
self._start_time = time.time()
self.set_status("connecting", "连接中")
self.append_log("正在连接企业微信窗口…", "notify")
self._thread.start()
def stop_monitoring(self) -> None:
if self._thread is None or not self._thread.is_alive():
self._finish_thread("stopped")
return
self._running = False
self._thread.stop()
self.set_status("stopping", "正在停止")
self.append_log("正在停止监听…", "warn")
def set_status(self, state: str, text: str) -> None:
self._status_key = state
hints = {
"connecting": "正在查找企业微信窗口",
"waiting": WECOM_WAITING_MESSAGE,
"running": "安全运行中,检测到未读消息后自动处理",
"stopping": "等待后台任务退出",
"error": "连接失败,请查看运行日志",
"stopped": "等待连接企业微信",
}
hint = hints.get(state, text)
self.sidebar.set_status(state, text, hint)
self.dashboard_page.set_status(state, text, hint)
self.capsule.set_status(state, text)
def enter_capsule_mode(self) -> None:
if self.capsule.isVisible():
return
self._saved_geometry = self.saveGeometry()
self._was_maximized = self.isMaximized()
self.capsule.show_near(self)
self.hide()
def expand_console(self) -> None:
self.capsule.hide()
if self._saved_geometry is not None:
self.restoreGeometry(self._saved_geometry)
if self._was_maximized:
self.showMaximized()
else:
self.showNormal()
self.raise_()
self.activateWindow()
def _finish_thread(self, final_state: str) -> None:
self._running = False
if self.capsule.isVisible():
self.expand_console()
if sys.stdout is self._stdout_proxy:
sys.stdout = self._original_stdout
self._stdout_proxy = None
if final_state == "error":
self.set_status("error", "连接失败")
self.show_page(5)
else:
self.set_status("stopped", "已停止")
self.dashboard_page.timer.setText("运行时长 --:--:--")
def append_log(self, message: str, tag: str = "") -> None:
self.log_page.append(message, tag)
def _persona_saved(self, ok: bool, message: str) -> None:
self.append_log(message, "ok" if ok else "err")
def _refresh_counts(self) -> None:
try:
self.business_page.refresh_data()
self.dashboard_page.registration.value.setText(
self.business_page.registration_metric.value.text()
)
self.dashboard_page.sessions.value.setText(
self.business_page.session_metric.value.text()
)
except Exception as exc:
self.append_log(f"刷新业务数据失败:{exc}", "err")
def _schedule_business_refresh(self) -> None:
"""合并短时间内的多条挂号日志,避免在队列循环中反复重建表格。"""
self.business_refresh_timer.start()
def _process_queue(self) -> None:
try:
while True:
kind, data = self._queue.get_nowait()
if kind == "log":
lowered = str(data).lower()
if any(token in str(data) for token in ("[+]", "回复完成", "已保存")):
tag = "ok"
elif any(token in str(data) for token in ("[!]", "误判", "停止")):
tag = "warn"
elif any(token in lowered for token in ("错误", "[-]", "失败", "exception", "traceback")):
tag = "err"
elif any(token in str(data) for token in ("[MCP]", "[挂号]", "启动", "连接")):
tag = "notify"
else:
tag = ""
self.append_log(str(data), tag)
if "[挂号]" in str(data):
self._schedule_business_refresh()
elif kind == "status":
if data == "running":
self.set_status("running", "监听中")
self.enter_capsule_mode()
elif data == "waiting":
self.set_status("waiting", "等待企业微信")
elif data == "error":
self._finish_thread("error")
elif data == "stopped":
self._finish_thread("stopped")
elif kind == "stats":
self.dashboard_page.replied.value.setText(str(data.get("replied", 0)))
self.dashboard_page.false_pos.value.setText(str(data.get("false_pos", 0)))
except KeyboardInterrupt:
# Qt 默认会把 Ctrl+C 打断留在当前槽函数中并打印堆栈;转为正常关闭。
QTimer.singleShot(0, self.close)
return
except queue.Empty:
pass
if self._start_time and self._running:
elapsed = int(time.time() - self._start_time)
runtime = f"{elapsed // 3600:02d}:{(elapsed % 3600) // 60:02d}:{elapsed % 60:02d}"
self.dashboard_page.timer.setText(f"运行时长 {runtime}")
self.capsule.timer.setText(runtime)
def closeEvent(self, event) -> None:
self.save_runtime_settings()
self._running = False
if self._thread is not None and self._thread.is_alive():
self._thread.stop()
if sys.stdout is self._stdout_proxy:
sys.stdout = self._original_stdout
self.capsule.close()
event.accept()
def _app_icon() -> QIcon:
pixmap = QPixmap(64, 64)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
painter.setBrush(QColor(COLORS["accent"]))
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(5, 5, 54, 54, 17, 17)
painter.setBrush(QColor("#FFFFFF"))
painter.drawEllipse(17, 18, 30, 24)
painter.drawPolygon([QPoint(25, 39), QPoint(22, 49), QPoint(34, 41)])
painter.end()
return QIcon(pixmap)
def install_console_shutdown_handler(app: QApplication, window: MainWindow):
"""将控制台 Ctrl+C / Ctrl+Break 转换为 Qt 的正常关闭流程。"""
shutdown_requested = False
def request_shutdown(_signum=None, _frame=None) -> None:
nonlocal shutdown_requested
if shutdown_requested:
return
shutdown_requested = True
QTimer.singleShot(0, window.close)
QTimer.singleShot(0, app.quit)
handled = [signal.SIGINT]
if hasattr(signal, "SIGBREAK"):
handled.append(signal.SIGBREAK)
for signal_number in handled:
try:
signal.signal(signal_number, request_shutdown)
except (OSError, ValueError):
pass
return request_shutdown
def main() -> None:
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
app = QApplication.instance() or QApplication(sys.argv)
app.setApplicationName(WIN_TITLE)
app.setApplicationDisplayName(WIN_TITLE)
app.setWindowIcon(_app_icon())
app.setStyle("Fusion")
app.setStyleSheet(APP_QSS)
font = QFont("Microsoft YaHei UI", 10)
font.setHintingPreference(QFont.PreferFullHinting)
app.setFont(font)
try:
import backend_client
backend_client.startup_sync_config(timeout=3.0)
except Exception:
# 启动检测失败不阻止软件打开,具体原因会显示在后台同步卡片中。
pass
window = MainWindow()
console_shutdown = install_console_shutdown_handler(app, window)
window.show()
if "--qt-smoke-test" in sys.argv:
for index in range(window.stack.count()):
def show_test_page(i=index) -> None:
window.show_page(i)
def capture(i=index) -> None:
window.grab().save(str(SCRIPT_DIR / f"qt-ui-smoke-{i + 1}.png"))
QTimer.singleShot(650 + index * 650, show_test_page)
QTimer.singleShot(950 + index * 650, capture)
QTimer.singleShot(1100 + window.stack.count() * 650, app.quit)
try:
exit_code = app.exec()
except KeyboardInterrupt:
console_shutdown()
exit_code = 0
raise SystemExit(exit_code)
if __name__ == "__main__":
main()