461 lines
16 KiB
Python
461 lines
16 KiB
Python
"""
|
||
PySide6 GUI界面模块
|
||
"""
|
||
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||
QPushButton, QSlider, QDialog, QFormLayout,
|
||
QLineEdit, QSpinBox, QMessageBox, QSystemTrayIcon,
|
||
QMenu, QTextEdit, QGroupBox, QDialogButtonBox, QCheckBox, QScrollArea)
|
||
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 = QFormLayout(self)
|
||
|
||
# LLM API 配置
|
||
llm_config = self.config_manager.llm_api_config
|
||
|
||
self.base_url_edit = QLineEdit(llm_config.get('base_url', ''))
|
||
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.timeout_spin = QSpinBox()
|
||
self.timeout_spin.setRange(10, 300)
|
||
self.timeout_spin.setValue(llm_config.get('timeout', 30))
|
||
|
||
layout.addRow("API Base URL:", self.base_url_edit)
|
||
layout.addRow("API Key:", self.api_key_edit)
|
||
layout.addRow("Model:", self.model_edit)
|
||
layout.addRow("Timeout (s):", self.timeout_spin)
|
||
|
||
# 爬虫配置
|
||
spider_config = self.config_manager.spider_config
|
||
|
||
self.url_edit = QLineEdit(spider_config.get('target_url', ''))
|
||
self.xpath_edit = QLineEdit(spider_config.get('xpath', ''))
|
||
self.user_agent_edit = QLineEdit(spider_config.get('user_agent', ''))
|
||
self.interval_spin = QSpinBox()
|
||
self.interval_spin.setRange(10, 3600)
|
||
self.interval_spin.setValue(spider_config.get('fetch_interval', 15))
|
||
|
||
layout.addRow("目标URL:", self.url_edit)
|
||
layout.addRow("XPath:", self.xpath_edit)
|
||
layout.addRow("User Agent:", self.user_agent_edit)
|
||
layout.addRow("刷新间隔(s):", self.interval_spin)
|
||
|
||
# UI 配置
|
||
ui_config = self.config_manager.ui_config
|
||
|
||
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.ontop_check = QCheckBox() if hasattr(self, 'QCheckBox') else None
|
||
# 使用 QPushButton 替代 QCheckBox
|
||
self.ontop_btn = QPushButton("置顶")
|
||
self.ontop_btn.setCheckable(True)
|
||
self.ontop_btn.setChecked(ui_config.get('is_on_top', True))
|
||
|
||
layout.addRow("透明度:", self.opacity_slider)
|
||
layout.addRow("窗口置顶:", self.ontop_btn)
|
||
|
||
# 阈值配置
|
||
thresholds = ui_config.get('thresholds', {})
|
||
self.cold_spin = QSpinBox()
|
||
self.cold_spin.setRange(0, 50)
|
||
self.cold_spin.setValue(thresholds.get('cold', 30))
|
||
self.warm_spin = QSpinBox()
|
||
self.warm_spin.setRange(50, 100)
|
||
self.warm_spin.setValue(thresholds.get('warm', 70))
|
||
|
||
layout.addRow("寒冷阈值:", self.cold_spin)
|
||
layout.addRow("温暖阈值:", self.warm_spin)
|
||
|
||
# 按钮
|
||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||
button_box.accepted.connect(self._save_config)
|
||
button_box.rejected.connect(self.reject)
|
||
layout.addRow(button_box)
|
||
|
||
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()
|
||
)
|
||
|
||
# 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(10, 10, 10, 10)
|
||
|
||
# 标题
|
||
self.title_label = QLabel("上证指数sh000001")
|
||
self.title_label.setAlignment(Qt.AlignCenter)
|
||
title_font = QFont()
|
||
title_font.setPointSize(14)
|
||
title_font.setBold(True)
|
||
self.title_label.setFont(title_font)
|
||
|
||
# 指示灯
|
||
self.indicator = SentimentIndicator()
|
||
self.score_label = QLabel("50 - 中性")
|
||
self.score_label.setAlignment(Qt.AlignCenter)
|
||
|
||
# 状态信息
|
||
self.status_label = QLabel("等待数据...")
|
||
self.status_label.setAlignment(Qt.AlignCenter)
|
||
status_font = QFont()
|
||
status_font.setPointSize(10)
|
||
self.status_label.setFont(status_font)
|
||
|
||
# 上证所截图显示
|
||
screenshot_group = QGroupBox("上证所行情")
|
||
screenshot_layout = QVBoxLayout(screenshot_group)
|
||
|
||
self.screenshot_label = QLabel("等待截图...")
|
||
self.screenshot_label.setAlignment(Qt.AlignCenter)
|
||
self.screenshot_label.setMinimumSize(400, 200)
|
||
self.screenshot_label.setStyleSheet("border: 1px solid #ccc; background-color: #f0f0f0;")
|
||
|
||
screenshot_scroll = QScrollArea()
|
||
screenshot_scroll.setWidget(self.screenshot_label)
|
||
screenshot_scroll.setWidgetResizable(True)
|
||
screenshot_scroll.setMinimumHeight(150)
|
||
|
||
screenshot_layout.addWidget(screenshot_scroll)
|
||
|
||
# 按钮
|
||
btn_layout = QHBoxLayout()
|
||
self.refresh_btn = QPushButton("刷新")
|
||
self.config_btn = QPushButton("配置")
|
||
self.quit_btn = QPushButton("退出")
|
||
self.quit_btn.clicked.connect(self.quit_app)
|
||
btn_layout.addWidget(self.refresh_btn)
|
||
btn_layout.addWidget(self.config_btn)
|
||
btn_layout.addWidget(self.quit_btn)
|
||
|
||
# 添加到主布局
|
||
layout.addWidget(self.title_label)
|
||
layout.addWidget(self.indicator)
|
||
layout.addWidget(self.score_label)
|
||
layout.addWidget(self.status_label)
|
||
layout.addWidget(screenshot_group)
|
||
layout.addLayout(btn_layout)
|
||
|
||
# 设置窗口标志(无边框、可拖拽)
|
||
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||
|
||
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)
|
||
config_action = QAction("配置", self)
|
||
opacity_action = QAction("透明度", self)
|
||
quit_action = QAction("退出", self)
|
||
|
||
config_action.triggered.connect(self.show_config)
|
||
quit_action.triggered.connect(self.quit_app)
|
||
|
||
context_menu.addAction(config_action)
|
||
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 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(f"{score} - {label}")
|
||
logger.debug(f"更新指示灯: {score}分 - {label}")
|
||
|
||
def update_status(self, text: str):
|
||
"""更新状态"""
|
||
self.status_label.setText(text)
|
||
logger.debug(f"更新状态: {text}")
|
||
|
||
def set_refresh_callback(self, callback: Callable):
|
||
"""设置刷新按钮回调"""
|
||
self.refresh_btn.clicked.connect(callback)
|
||
logger.debug("设置刷新按钮回调")
|
||
|
||
def set_config_callback(self, callback: Callable):
|
||
"""设置配置按钮回调"""
|
||
self.config_btn.clicked.connect(callback)
|
||
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("截图加载失败")
|
||
|
||
|
||
class QCheckBox(QPushButton):
|
||
"""自定义复选框"""
|
||
def __init__(self, text=""):
|
||
super().__init__(text)
|
||
self.setCheckable(True)
|
||
self.setChecked(False)
|