Files
guba-indicator/main_window.py
xiaji ee721e9abe feat(ui): 重构配置对话框和主窗口界面
- 将配置对话框改为标签页布局,分为API配置、爬虫配置和界面设置
- 优化主窗口UI,包括指示灯样式、分数显示和截图区域
- 添加窗口圆角和半透明效果
- 改进右键菜单功能,增加刷新操作
- 优化状态显示和分数颜色标识
2026-01-28 17:30:30 +08:00

618 lines
22 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.
"""
PySide6 GUI界面模块
"""
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QSlider, QDialog, QFormLayout,
QLineEdit, QSpinBox, QMessageBox, QSystemTrayIcon,
QMenu, QTextEdit, QGroupBox, QDialogButtonBox, QCheckBox, QScrollArea, QFileDialog,
QTabWidget)
from PySide6.QtCore import Qt, QTimer, Signal, QPoint
from PySide6.QtGui import QFont, QColor, QPainter, QBrush, QPen, QIcon, QAction, QPixmap
from typing import Callable, Optional
from loguru import logger
class SentimentIndicator(QWidget):
"""情感指示灯组件"""
def __init__(self, parent=None):
super().__init__(parent)
self.score = 50
self.label_text = "中性"
self.setMinimumSize(100, 100)
def set_value(self, score: int, label: str = None):
"""设置数值和标签"""
self.score = max(0, min(100, score))
if label:
self.label_text = label
self.update()
def paintEvent(self, event):
"""绘制指示灯"""
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
center = self.rect().center()
radius = min(self.width(), self.height()) // 2 - 10
# 根据分数确定颜色
color = self._get_color(self.score)
# 绘制外圈
painter.setPen(QPen(QColor(100, 100, 100), 2))
painter.setBrush(QBrush(QColor(30, 30, 30)))
painter.drawEllipse(center, radius, radius)
# 绘制内圈(渐变效果)
gradient_color = QColor(color)
painter.setPen(Qt.NoPen)
painter.setBrush(QBrush(gradient_color))
painter.drawEllipse(center, radius - 4, radius - 4)
# 绘制发光效果
for i in range(3, 0, -1):
glow_color = QColor(color)
glow_color.setAlpha(50 // i)
painter.setBrush(QBrush(glow_color))
painter.drawEllipse(center, radius - i * 5, radius - i * 5)
def _get_color(self, score: int) -> QColor:
"""根据分数获取颜色"""
# 45-55分区间每1分一个颜色从绿色到黄色渐变
if 45 <= score <= 55:
# 在45-55分之间每1分一个颜色
ratio = (score - 45) / 10 # 0到1之间的比例
# 从绿色(0, 200, 0)渐变到黄色(255, 255, 0)
r = int(0 + 255 * ratio)
g = 200 if ratio < 0.5 else int(200 + 55 * (ratio - 0.5) * 2)
b = int(0 + 0 * ratio)
return QColor(r, g, b)
# 45分以下每5分一个颜色从深蓝到绿色渐变
elif score < 45:
# 将分数映射到0-8的区间0-40分每5分一个级别
level = score // 5
# 从深蓝色(0, 100, 255)渐变到绿色(0, 200, 0)
ratio = level / 8 # 0-8对应0-40分
r = int(0 + 0 * ratio)
g = int(100 + 100 * ratio)
b = int(255 - 255 * ratio)
return QColor(r, g, b)
# 55分以上每5分一个颜色从黄色到红色渐变
else:
# 将分数映射到0-8的区间60-100分每5分一个级别
level = (score - 60) // 5
level = max(0, min(8, level)) # 限制在0-8范围内
# 从黄色(255, 255, 0)渐变到红色(255, 0, 0)
ratio = level / 8
r = 255
g = int(255 - 255 * ratio)
b = 0
return QColor(r, g, b)
def get_description(self, score: int) -> str:
"""获取描述文本"""
if score < 30:
return "极度悲观"
elif score < 39:
return "悲观"
elif score < 45:
return "偏悲观"
elif score < 55:
return "中立"
elif score < 65:
return "偏乐观"
elif score < 70:
return "乐观"
else:
return "极度乐观"
class ConfigDialog(QDialog):
"""配置对话框"""
def __init__(self, config_manager, parent=None):
super().__init__(parent)
self.config_manager = config_manager
self.setWindowTitle("配置")
self.setMinimumWidth(400)
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# 创建标签页
tab_widget = QTabWidget()
tab_widget.setStyleSheet(
"QTabWidget::pane { border: none; }"
"QTabBar::tab { padding: 12px 20px; font-size: 13px; }"
"QTabBar::tab:selected { background-color: #2196F3; color: white; }"
)
# API配置标签页
api_tab = QWidget()
api_layout = QFormLayout(api_tab)
api_layout.setContentsMargins(20, 20, 20, 20)
api_layout.setSpacing(12)
llm_config = self.config_manager.llm_api_config
self.base_url_edit = QLineEdit(llm_config.get('base_url', ''))
self.base_url_edit.setPlaceholderText("https://api.openai.com/v1")
self.api_key_edit = QLineEdit(llm_config.get('api_key', ''))
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.model_edit = QLineEdit(llm_config.get('model', ''))
self.model_edit.setPlaceholderText("gpt-3.5-turbo")
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(10, 300)
self.timeout_spin.setValue(llm_config.get('timeout', 30))
self.timeout_spin.setSuffix("")
api_layout.addRow("API Base URL:", self.base_url_edit)
api_layout.addRow("API Key:", self.api_key_edit)
api_layout.addRow("Model:", self.model_edit)
api_layout.addRow("超时时间:", self.timeout_spin)
# 爬虫配置标签页
spider_tab = QWidget()
spider_layout = QFormLayout(spider_tab)
spider_layout.setContentsMargins(20, 20, 20, 20)
spider_layout.setSpacing(12)
spider_config = self.config_manager.spider_config
self.url_edit = QLineEdit(spider_config.get('target_url', ''))
self.url_edit.setPlaceholderText("https://example.com")
self.xpath_edit = QLineEdit(spider_config.get('xpath', ''))
self.xpath_edit.setPlaceholderText("//div[@class='content']")
self.user_agent_edit = QLineEdit(spider_config.get('user_agent', ''))
self.user_agent_edit.setPlaceholderText("Mozilla/5.0...")
self.interval_spin = QSpinBox()
self.interval_spin.setRange(10, 3600)
self.interval_spin.setValue(spider_config.get('fetch_interval', 15))
self.interval_spin.setSuffix("")
spider_layout.addRow("目标 URL:", self.url_edit)
spider_layout.addRow("XPath 表达式:", self.xpath_edit)
spider_layout.addRow("User Agent:", self.user_agent_edit)
spider_layout.addRow("刷新间隔:", self.interval_spin)
# Chrome浏览器路径
chrome_path_layout = QHBoxLayout()
chrome_path_layout.setSpacing(8)
self.chrome_path_edit = QLineEdit(spider_config.get('chrome_path', ''))
self.chrome_path_edit.setPlaceholderText("留空则自动查找Chrome浏览器")
self.chrome_browse_btn = QPushButton("浏览...")
self.chrome_browse_btn.setFixedWidth(60)
self.chrome_browse_btn.clicked.connect(self._browse_chrome_path)
chrome_path_layout.addWidget(self.chrome_path_edit)
chrome_path_layout.addWidget(self.chrome_browse_btn)
spider_layout.addRow("Chrome 路径:", chrome_path_layout)
# 界面配置标签页
ui_tab = QWidget()
ui_layout = QFormLayout(ui_tab)
ui_layout.setContentsMargins(20, 20, 20, 20)
ui_layout.setSpacing(12)
ui_config = self.config_manager.ui_config
# 透明度
opacity_layout = QHBoxLayout()
opacity_layout.setSpacing(8)
self.opacity_slider = QSlider(Qt.Horizontal)
self.opacity_slider.setRange(30, 100)
self.opacity_slider.setValue(int(ui_config.get('opacity', 0.9) * 100))
self.opacity_slider.setFixedWidth(120)
self.opacity_label = QLabel(f"{int(ui_config.get('opacity', 0.9) * 100)}%")
self.opacity_label.setFixedWidth(40)
self.opacity_slider.valueChanged.connect(
lambda v: self.opacity_label.setText(f"{v}%")
)
opacity_layout.addWidget(self.opacity_slider)
opacity_layout.addWidget(self.opacity_label)
opacity_layout.addStretch()
# 窗口置顶
self.ontop_btn = QPushButton("窗口置顶")
self.ontop_btn.setCheckable(True)
self.ontop_btn.setFixedWidth(100)
self.ontop_btn.setChecked(ui_config.get('is_on_top', True))
self.ontop_btn.setStyleSheet(
"QPushButton { background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; padding: 6px; }"
"QPushButton:checked { background-color: #2196F3; color: white; }"
)
ui_layout.addRow("透明度:", opacity_layout)
ui_layout.addRow("窗口行为:", self.ontop_btn)
# 阈值配置
thresholds = ui_config.get('thresholds', {})
threshold_layout = QHBoxLayout()
threshold_layout.setSpacing(12)
self.cold_spin = QSpinBox()
self.cold_spin.setRange(0, 50)
self.cold_spin.setValue(thresholds.get('cold', 30))
self.cold_spin.setSuffix("")
self.cold_spin.setFixedWidth(80)
self.warm_spin = QSpinBox()
self.warm_spin.setRange(50, 100)
self.warm_spin.setValue(thresholds.get('warm', 70))
self.warm_spin.setSuffix("")
self.warm_spin.setFixedWidth(80)
threshold_layout.addWidget(QLabel("寒冷:"))
threshold_layout.addWidget(self.cold_spin)
threshold_layout.addWidget(QLabel("温暖:"))
threshold_layout.addWidget(self.warm_spin)
threshold_layout.addStretch()
ui_layout.addRow("阈值设置:", threshold_layout)
# 添加标签页
tab_widget.addTab(api_tab, "API 配置")
tab_widget.addTab(spider_tab, "爬虫配置")
tab_widget.addTab(ui_tab, "界面设置")
layout.addWidget(tab_widget)
# 底部按钮
button_layout = QHBoxLayout()
button_layout.setContentsMargins(20, 0, 20, 20)
button_layout.setSpacing(8)
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(self._save_config)
button_box.rejected.connect(self.reject)
# 设置按钮样式
ok_btn = button_box.button(QDialogButtonBox.Ok)
cancel_btn = button_box.button(QDialogButtonBox.Cancel)
ok_btn.setStyleSheet(
"QPushButton { background-color: #2196F3; color: white; border: none; border-radius: 4px; padding: 8px 16px; }"
"QPushButton:hover { background-color: #1976D2; }"
)
cancel_btn.setStyleSheet(
"QPushButton { background-color: #f5f5f5; color: #333; border: 1px solid #ddd; border-radius: 4px; padding: 8px 16px; }"
"QPushButton:hover { background-color: #e0e0e0; }"
)
button_layout.addStretch()
button_layout.addWidget(button_box)
layout.addLayout(button_layout)
def _browse_chrome_path(self):
"""浏览Chrome路径"""
file_path, _ = QFileDialog.getOpenFileName(
self,
"选择Chrome浏览器可执行文件",
"",
"Chrome浏览器 (*.exe);;所有文件 (*.*)"
)
if file_path:
self.chrome_path_edit.setText(file_path)
def _save_config(self):
"""保存配置"""
# LLM API
self.config_manager.update_llm_api(
base_url=self.base_url_edit.text(),
api_key=self.api_key_edit.text(),
model=self.model_edit.text(),
timeout=self.timeout_spin.value()
)
# 爬虫
self.config_manager.update_spider(
target_url=self.url_edit.text(),
xpath=self.xpath_edit.text(),
user_agent=self.user_agent_edit.text(),
fetch_interval=self.interval_spin.value(),
chrome_path=self.chrome_path_edit.text()
)
# UI
self.config_manager.update_ui(
opacity=self.opacity_slider.value() / 100.0,
is_on_top=self.ontop_btn.isChecked(),
cold_threshold=self.cold_spin.value(),
warm_threshold=self.warm_spin.value()
)
self.accept()
class MainWindow(QWidget):
"""主窗口"""
def __init__(self, config_manager, spider_manager=None, parent=None):
super().__init__(parent)
self.config_manager = config_manager
self.spider_manager = spider_manager
# 获取页面标题并设置窗口标题
self._set_window_title()
self._init_ui()
self._apply_config()
# 拖拽相关
self.dragging = False
self.drag_position = QPoint()
# 系统托盘
self._init_tray_icon()
def _init_ui(self):
"""初始化UI"""
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
# 标题
self.title_label = QLabel("上证指数 sh000001")
self.title_label.setAlignment(Qt.AlignCenter)
title_font = QFont()
title_font.setPointSize(16)
title_font.setBold(True)
self.title_label.setFont(title_font)
self.title_label.setStyleSheet("color: #333;")
# 指示灯
self.indicator = SentimentIndicator()
self.indicator.setMinimumSize(120, 120)
# 分数和标签
self.score_label = QLabel("50")
self.score_label.setAlignment(Qt.AlignCenter)
score_font = QFont()
score_font.setPointSize(24)
score_font.setBold(True)
self.score_label.setFont(score_font)
self.score_label.setStyleSheet("color: #2196F3;")
self.sentiment_label = QLabel("中性")
self.sentiment_label.setAlignment(Qt.AlignCenter)
sentiment_font = QFont()
sentiment_font.setPointSize(12)
self.sentiment_label.setFont(sentiment_font)
self.sentiment_label.setStyleSheet("color: #666;")
# 状态信息
self.status_label = QLabel("等待数据...")
self.status_label.setAlignment(Qt.AlignCenter)
self.status_label.setStyleSheet("color: #999; font-size: 11px;")
# 上证所截图显示
self.screenshot_label = QLabel("等待截图...")
self.screenshot_label.setAlignment(Qt.AlignCenter)
self.screenshot_label.setMinimumSize(380, 180)
self.screenshot_label.setMaximumHeight(200)
self.screenshot_label.setStyleSheet(
"QLabel {"
" border: 1px solid #e0e0e0;"
" border-radius: 8px;"
" background-color: #f5f5f5;"
"}"
)
# 添加到主布局
layout.addWidget(self.title_label)
layout.addWidget(self.indicator, alignment=Qt.AlignCenter)
layout.addWidget(self.score_label)
layout.addWidget(self.sentiment_label)
layout.addWidget(self.screenshot_label)
layout.addWidget(self.status_label)
# 设置窗口标志(无边框、可拖拽)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setStyleSheet(
"QWidget {"
" background-color: rgba(255, 255, 255, 0.95);"
" border-radius: 12px;"
"}"
)
def _set_window_title(self):
"""设置窗口标题"""
logger.debug("设置窗口标题")
# 尝试从爬虫获取页面标题
if hasattr(self, 'spider_manager') and self.spider_manager:
try:
page_title = self.spider_manager.get_page_title()
if page_title:
# 从页面标题中提取股票名称
import re
match = re.search(r'(上证指数sh\d+)', page_title)
if match:
stock_name = match.group(1)
window_title = f"冷暖值 - {stock_name}"
self.setWindowTitle(window_title)
# 同时更新主标题标签(如果已初始化)
if hasattr(self, 'title_label'):
self.title_label.setText("上证指数sh000001")
logger.info(f"设置窗口标题: {window_title}")
return
except Exception as e:
logger.error(f"获取页面标题失败: {e}")
# 如果获取失败,使用默认标题
self.setWindowTitle("冷暖值 - 股吧人气")
logger.info("使用默认窗口标题")
def _init_tray_icon(self):
"""初始化系统托盘"""
logger.debug("初始化系统托盘")
self.tray_icon = QSystemTrayIcon(self)
self.tray_icon.setToolTip("股吧人气指示器")
# 创建托盘菜单
tray_menu = QMenu()
show_action = QAction("显示", self)
hide_action = QAction("隐藏", self)
quit_action = QAction("退出", self)
show_action.triggered.connect(self.show)
hide_action.triggered.connect(self.hide)
quit_action.triggered.connect(self.quit_app)
tray_menu.addAction(show_action)
tray_menu.addAction(hide_action)
tray_menu.addAction(quit_action)
self.tray_icon.setContextMenu(tray_menu)
self.tray_icon.show()
logger.info("系统托盘初始化完成")
def quit_app(self):
"""退出应用"""
logger.info("退出应用")
self.close()
import sys
sys.exit(0)
def _apply_config(self):
"""应用配置"""
ui_config = self.config_manager.ui_config
self.setWindowOpacity(ui_config.get('opacity', 0.9))
if ui_config.get('is_on_top', True):
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
else:
self.setWindowFlags(self.windowFlags() & ~Qt.WindowStaysOnTopHint)
thresholds = ui_config.get('thresholds', {})
def mousePressEvent(self, event):
"""鼠标按下事件"""
if event.button() == Qt.LeftButton:
self.dragging = True
self.drag_position = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
def mouseMoveEvent(self, event):
"""鼠标移动事件"""
if self.dragging:
self.move(event.globalPosition().toPoint() - self.drag_position)
def mouseReleaseEvent(self, event):
"""鼠标释放事件"""
if event.button() == Qt.LeftButton:
self.dragging = False
def contextMenuEvent(self, event):
"""右键菜单"""
context_menu = QMenu(self)
refresh_action = QAction("刷新", self)
config_action = QAction("配置", self)
quit_action = QAction("退出", self)
refresh_action.triggered.connect(self._on_refresh)
config_action.triggered.connect(self.show_config)
quit_action.triggered.connect(self.quit_app)
context_menu.addAction(refresh_action)
context_menu.addAction(config_action)
context_menu.addSeparator()
context_menu.addAction(quit_action)
context_menu.exec(event.globalPos())
def show_config(self):
"""显示配置对话框"""
dialog = ConfigDialog(self.config_manager, self)
if dialog.exec() == QDialog.Accepted:
self._apply_config()
def _on_refresh(self):
"""刷新回调"""
if hasattr(self, '_refresh_callback') and self._refresh_callback:
self._refresh_callback()
logger.info("执行刷新操作")
def update_indicator(self, score: int, label: str = None):
"""更新指示灯"""
if label is None:
label = self.indicator.get_description(score)
self.indicator.set_value(score, label)
self.score_label.setText(str(score))
self.sentiment_label.setText(label)
color = self._get_score_color(score)
self.score_label.setStyleSheet(f"color: {color}; font-size: 24px; font-weight: bold;")
logger.debug(f"更新指示灯: {score}分 - {label}")
def _get_score_color(self, score: int) -> str:
"""根据分数获取颜色值"""
if score < 30:
return "#1565C0"
elif score < 39:
return "#1976D2"
elif score < 45:
return "#42A5F5"
elif score < 55:
return "#66BB6A"
elif score < 65:
return "#FFA726"
elif score < 70:
return "#FB8C00"
else:
return "#E53935"
def update_status(self, text: str):
"""更新状态"""
self.status_label.setText(text)
logger.debug(f"更新状态: {text}")
def set_refresh_callback(self, callback: Callable):
"""设置刷新回调"""
self._refresh_callback = callback
logger.debug("设置刷新回调")
def set_config_callback(self, callback: Callable):
"""设置配置回调(已废弃,配置直接通过右键菜单调用)"""
logger.debug("配置回调已废弃,配置直接通过右键菜单调用")
def show_message(self, title: str, message: str):
"""显示消息"""
logger.info(f"显示消息: {title} - {message}")
QMessageBox.information(self, title, message)
def update_sse_screenshot(self, screenshot_path: str):
"""更新上证所截图显示"""
logger.info(f"更新截图显示: {screenshot_path}")
pixmap = QPixmap(screenshot_path)
if not pixmap.isNull():
self.screenshot_label.setPixmap(pixmap.scaled(
self.screenshot_label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation
))
self.screenshot_label.setText("")
logger.info("截图显示更新成功")
else:
self.screenshot_label.setText("截图加载失败")
logger.warning("截图加载失败")
def add_waveform_data(self, time_str: str, value: float):
"""添加波形图数据点(暂未实现波形图功能)"""
logger.debug(f"波形图数据: 时间={time_str}, 值={value}")
class QCheckBox(QPushButton):
"""自定义复选框"""
def __init__(self, text=""):
super().__init__(text)
self.setCheckable(True)
self.setChecked(False)