Files
2026-07-23 11:30:11 +08:00

2116 lines
82 KiB
Python
Raw Permalink 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 -*-
"""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(
"<html><body style='margin:0;background:#f4f7f5;font-family:Microsoft YaHei UI'>"
"<div style='height:100vh;display:grid;place-items:center;color:#617269'>"
"AI 客服网页在测试模式下未加载</div></body></html>"
)
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\n", "<br>")
)
self.editor.append(
f'<span style="color:#94A29B">[{stamp}]</span> '
f'<span style="color:{color}">{safe}</span>'
)
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()