初始化

This commit is contained in:
2025-12-11 09:47:00 +08:00
commit 99933d62f8
65 changed files with 121176 additions and 0 deletions

31
ui/__init__.py Normal file
View File

@@ -0,0 +1,31 @@
"""
UI 模块
- 主窗口
- 侧边栏
- 工作区
- 动画效果
"""
from .main_window import MainWindow
from .sidebar import PrimarySidebar
from .tool_list import SecondarySidebar
from .workspace import BaseWorkspace, WelcomeWorkspace, UploadArea
from .settings import SettingsPage
from .log_viewer import LogViewer
from .image_preview import ImagePreviewWidget, DualPreviewWidget
from .animations import AnimationMixin, animate_widget_show, animate_widget_hide
__all__ = [
'MainWindow',
'PrimarySidebar',
'SecondarySidebar',
'BaseWorkspace',
'WelcomeWorkspace',
'UploadArea',
'SettingsPage',
'LogViewer',
'ImagePreviewWidget',
'DualPreviewWidget',
'AnimationMixin',
'animate_widget_show',
'animate_widget_hide'
]

183
ui/animations.py Normal file
View File

@@ -0,0 +1,183 @@
"""
UI动画效果模块
- 渐入渐出
- 滑动效果
- 缩放效果
"""
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
from PySide6.QtCore import (
QPropertyAnimation, QEasingCurve, QParallelAnimationGroup,
QSequentialAnimationGroup, Property, QPoint, QSize
)
from PySide6.QtGui import QColor
from core.config import config
class AnimationMixin:
"""动画混入类,为QWidget添加动画功能"""
def setup_animation(self):
"""初始化动画效果"""
if not hasattr(self, '_opacity_effect'):
self._opacity_effect = QGraphicsOpacityEffect(self)
self._opacity_effect.setOpacity(1.0)
self.setGraphicsEffect(self._opacity_effect)
def fade_in(self, duration: int = None, callback=None):
"""渐入动画"""
if not config.get("animation_enabled", True):
if callback:
callback()
return
self.setup_animation()
duration = duration or config.get("animation_duration", 300)
self._opacity_effect.setOpacity(0)
self.show()
self._fade_anim = QPropertyAnimation(self._opacity_effect, b"opacity")
self._fade_anim.setDuration(duration)
self._fade_anim.setStartValue(0.0)
self._fade_anim.setEndValue(1.0)
self._fade_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
if callback:
self._fade_anim.finished.connect(callback)
self._fade_anim.start()
def fade_out(self, duration: int = None, callback=None):
"""渐出动画"""
if not config.get("animation_enabled", True):
self.hide()
if callback:
callback()
return
self.setup_animation()
duration = duration or config.get("animation_duration", 300)
self._fade_anim = QPropertyAnimation(self._opacity_effect, b"opacity")
self._fade_anim.setDuration(duration)
self._fade_anim.setStartValue(1.0)
self._fade_anim.setEndValue(0.0)
self._fade_anim.setEasingCurve(QEasingCurve.Type.InCubic)
def on_finished():
self.hide()
self._opacity_effect.setOpacity(1.0)
if callback:
callback()
self._fade_anim.finished.connect(on_finished)
self._fade_anim.start()
def create_fade_animation(widget: QWidget, fade_in: bool = True, duration: int = None) -> QPropertyAnimation:
"""创建渐变动画"""
if not config.get("animation_enabled", True):
return None
duration = duration or config.get("animation_duration", 300)
# 确保有透明度效果
effect = widget.graphicsEffect()
if not isinstance(effect, QGraphicsOpacityEffect):
effect = QGraphicsOpacityEffect(widget)
widget.setGraphicsEffect(effect)
anim = QPropertyAnimation(effect, b"opacity")
anim.setDuration(duration)
if fade_in:
anim.setStartValue(0.0)
anim.setEndValue(1.0)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
else:
anim.setStartValue(1.0)
anim.setEndValue(0.0)
anim.setEasingCurve(QEasingCurve.Type.InCubic)
return anim
def create_slide_animation(widget: QWidget, start_pos: QPoint, end_pos: QPoint,
duration: int = None) -> QPropertyAnimation:
"""创建滑动动画"""
if not config.get("animation_enabled", True):
widget.move(end_pos)
return None
duration = duration or config.get("animation_duration", 300)
anim = QPropertyAnimation(widget, b"pos")
anim.setDuration(duration)
anim.setStartValue(start_pos)
anim.setEndValue(end_pos)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
return anim
def animate_widget_show(widget: QWidget, direction: str = "fade"):
"""显示控件动画"""
if not config.get("animation_enabled", True):
widget.show()
return
duration = config.get("animation_duration", 300)
if direction == "fade":
effect = QGraphicsOpacityEffect(widget)
effect.setOpacity(0)
widget.setGraphicsEffect(effect)
widget.show()
anim = QPropertyAnimation(effect, b"opacity")
anim.setDuration(duration)
anim.setStartValue(0.0)
anim.setEndValue(1.0)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
anim.start()
# 保持引用
widget._show_anim = anim
else:
widget.show()
def animate_widget_hide(widget: QWidget, callback=None):
"""隐藏控件动画"""
if not config.get("animation_enabled", True):
widget.hide()
if callback:
callback()
return
duration = config.get("animation_duration", 300)
effect = widget.graphicsEffect()
if not isinstance(effect, QGraphicsOpacityEffect):
effect = QGraphicsOpacityEffect(widget)
widget.setGraphicsEffect(effect)
anim = QPropertyAnimation(effect, b"opacity")
anim.setDuration(duration)
anim.setStartValue(1.0)
anim.setEndValue(0.0)
anim.setEasingCurve(QEasingCurve.Type.InCubic)
def on_finished():
widget.hide()
effect.setOpacity(1.0)
if callback:
callback()
anim.finished.connect(on_finished)
anim.start()
# 保持引用
widget._hide_anim = anim

379
ui/image_preview.py Normal file
View File

@@ -0,0 +1,379 @@
"""
图片预览组件
- 原图/处理后对比预览
- 缩放/平移
- 信息显示
"""
import os
from pathlib import Path
from PIL import Image
import io
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFrame, QScrollArea, QSplitter, QFileDialog, QMessageBox
)
from PySide6.QtCore import Qt, Signal, QSize
from PySide6.QtGui import QPixmap, QImage, QFont
from core.config import config
class ImagePreviewWidget(QFrame):
"""单个图片预览组件"""
def __init__(self, title: str = "预览", parent=None):
super().__init__(parent)
self.title = title
self._pixmap = None
self._image_info = {}
self.setup_ui()
def setup_ui(self):
self.setStyleSheet("""
ImagePreviewWidget {
background: rgba(15, 23, 42, 0.5);
border: 1px solid #334155;
border-radius: 12px;
}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# 标题栏
header = QHBoxLayout()
self.title_label = QLabel(self.title)
self.title_label.setStyleSheet("""
background: rgba(0, 0, 0, 0.5);
color: white;
font-size: 11px;
padding: 4px 10px;
border-radius: 4px;
""")
header.addWidget(self.title_label)
header.addStretch()
# 尺寸信息
self.size_label = QLabel("")
self.size_label.setStyleSheet("color: #64748b; font-size: 10px;")
header.addWidget(self.size_label)
layout.addLayout(header)
# 图片显示区域
self.image_container = QFrame()
self.image_container.setStyleSheet("""
background: #0f172a;
border-radius: 8px;
""")
self.image_container.setMinimumHeight(250)
container_layout = QVBoxLayout(self.image_container)
container_layout.setContentsMargins(8, 8, 8, 8)
container_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
# 占位图标
self.placeholder_icon = QLabel("🖼️")
self.placeholder_icon.setFont(QFont("Segoe UI Emoji", 40))
self.placeholder_icon.setStyleSheet("color: #334155;")
self.placeholder_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
container_layout.addWidget(self.placeholder_icon)
# 占位文字
self.placeholder_text = QLabel("暂无图片")
self.placeholder_text.setStyleSheet("color: #64748b; font-size: 12px;")
self.placeholder_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
container_layout.addWidget(self.placeholder_text)
# 图片标签
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setVisible(False)
container_layout.addWidget(self.image_label)
layout.addWidget(self.image_container, 1)
# 文件信息
self.info_label = QLabel("")
self.info_label.setStyleSheet("color: #94a3b8; font-size: 11px;")
self.info_label.setWordWrap(True)
layout.addWidget(self.info_label)
def set_image(self, pixmap: QPixmap = None, file_path: str = None,
pil_image: Image.Image = None, image_bytes: bytes = None):
"""设置预览图片"""
if pixmap:
self._pixmap = pixmap
elif file_path and os.path.exists(file_path):
self._pixmap = QPixmap(file_path)
self._image_info = self._get_file_info(file_path)
elif pil_image:
self._pixmap = self._pil_to_pixmap(pil_image)
elif image_bytes:
self._pixmap = QPixmap()
self._pixmap.loadFromData(image_bytes)
else:
self._pixmap = None
self._update_display()
def set_image_from_bytes(self, data: bytes, info: dict = None):
"""从字节数据设置图片"""
self._pixmap = QPixmap()
self._pixmap.loadFromData(data)
if info:
self._image_info = info
self._update_display()
def _pil_to_pixmap(self, pil_image: Image.Image) -> QPixmap:
"""PIL Image转QPixmap"""
if pil_image.mode == "RGB":
data = pil_image.tobytes("raw", "RGB")
qimage = QImage(data, pil_image.width, pil_image.height,
pil_image.width * 3, QImage.Format.Format_RGB888)
elif pil_image.mode == "RGBA":
data = pil_image.tobytes("raw", "RGBA")
qimage = QImage(data, pil_image.width, pil_image.height,
pil_image.width * 4, QImage.Format.Format_RGBA8888)
else:
pil_image = pil_image.convert("RGB")
data = pil_image.tobytes("raw", "RGB")
qimage = QImage(data, pil_image.width, pil_image.height,
pil_image.width * 3, QImage.Format.Format_RGB888)
return QPixmap.fromImage(qimage)
def _get_file_info(self, file_path: str) -> dict:
"""获取文件信息"""
try:
size = os.path.getsize(file_path)
with Image.open(file_path) as img:
return {
"path": file_path,
"name": Path(file_path).name,
"width": img.width,
"height": img.height,
"size": size,
"format": img.format
}
except:
return {}
def _update_display(self):
"""更新显示"""
if self._pixmap and not self._pixmap.isNull():
# 隐藏占位符
self.placeholder_icon.setVisible(False)
self.placeholder_text.setVisible(False)
self.image_label.setVisible(True)
# 缩放图片适应容器
container_size = self.image_container.size()
max_width = container_size.width() - 20
max_height = container_size.height() - 20
scaled = self._pixmap.scaled(
QSize(max_width, max_height),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
self.image_label.setPixmap(scaled)
# 更新尺寸信息
self.size_label.setText(f"{self._pixmap.width()}×{self._pixmap.height()}")
# 更新文件信息
if self._image_info:
size_str = self._format_size(self._image_info.get("size", 0))
self.info_label.setText(
f"📁 {self._image_info.get('name', '')} | 💾 {size_str}"
)
else:
# 显示占位符
self.placeholder_icon.setVisible(True)
self.placeholder_text.setVisible(True)
self.image_label.setVisible(False)
self.size_label.setText("")
self.info_label.setText("")
def set_info(self, info: dict):
"""设置图片信息"""
self._image_info = info
if info:
size_str = self._format_size(info.get("size", 0))
self.info_label.setText(
f"📁 {info.get('name', '')} | 💾 {size_str}"
)
def clear(self):
"""清空预览"""
self._pixmap = None
self._image_info = {}
self._update_display()
def resizeEvent(self, event):
"""窗口大小变化时重新缩放图片"""
super().resizeEvent(event)
if self._pixmap and not self._pixmap.isNull():
self._update_display()
@staticmethod
def _format_size(size: int) -> str:
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
class DualPreviewWidget(QWidget):
"""双栏预览组件 - 原图/处理后对比"""
save_requested = Signal(object) # 发出保存请求信号,携带处理后的数据
def __init__(self, parent=None):
super().__init__(parent)
self._processed_data = None # 保存处理后的数据
self._output_filename = ""
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(16)
# 预览区 - 左右分栏
preview_layout = QHBoxLayout()
preview_layout.setSpacing(16)
# 原图预览
self.original_preview = ImagePreviewWidget("📷 原图")
preview_layout.addWidget(self.original_preview, 1)
# 处理后预览
self.result_preview = ImagePreviewWidget("✨ 处理结果")
preview_layout.addWidget(self.result_preview, 1)
layout.addLayout(preview_layout, 1)
# 对比信息
self.compare_label = QLabel("")
self.compare_label.setStyleSheet("""
color: #22c55e;
font-size: 13px;
padding: 8px;
background: rgba(34, 197, 94, 0.1);
border-radius: 8px;
""")
self.compare_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.compare_label.setVisible(False)
layout.addWidget(self.compare_label)
# 操作按钮
btn_layout = QHBoxLayout()
btn_layout.addStretch()
self.save_btn = QPushButton("💾 保存结果")
self.save_btn.setObjectName("primary_btn")
self.save_btn.setMinimumSize(150, 40)
self.save_btn.clicked.connect(self._on_save_clicked)
self.save_btn.setEnabled(False)
btn_layout.addWidget(self.save_btn)
layout.addLayout(btn_layout)
def set_original(self, file_path: str):
"""设置原图"""
self.original_preview.set_image(file_path=file_path)
def set_result(self, data: bytes, info: dict = None, filename: str = "",
show_size_compare: bool = True):
"""设置处理结果"""
self._processed_data = data
self._output_filename = filename
self.result_preview.set_image_from_bytes(data, info)
self.save_btn.setEnabled(True)
# 显示对比信息(仅在压缩场景下显示大小对比)
if info and show_size_compare:
original_size = self.original_preview._image_info.get("size", 0)
result_size = info.get("size", len(data))
if original_size > 0:
saved = original_size - result_size
percent = (saved / original_size) * 100
if saved > 0:
self.compare_label.setText(
f"✅ 处理完成! 节省 {self._format_size(saved)} ({percent:.1f}%)"
)
self.compare_label.setStyleSheet("""
color: #22c55e;
font-size: 13px;
padding: 8px;
background: rgba(34, 197, 94, 0.1);
border-radius: 8px;
""")
self.compare_label.setVisible(True)
else:
# 文件变大或相同,不显示对比(可能是添加水印等操作)
self.compare_label.setVisible(False)
elif not show_size_compare:
# 不需要显示大小对比(如水印、格式转换)
self.compare_label.setText("✅ 处理完成!")
self.compare_label.setStyleSheet("""
color: #22c55e;
font-size: 13px;
padding: 8px;
background: rgba(34, 197, 94, 0.1);
border-radius: 8px;
""")
self.compare_label.setVisible(True)
def _on_save_clicked(self):
"""保存按钮点击"""
if not self._processed_data:
return
# 检查是否有默认保存路径
default_dir = config.get_output_directory()
auto_save = config.get("auto_save_to_default", False)
if default_dir and auto_save:
# 自动保存
save_path = os.path.join(default_dir, self._output_filename)
else:
# 询问保存位置
save_path, _ = QFileDialog.getSaveFileName(
self, "保存文件",
os.path.join(default_dir, self._output_filename) if default_dir else self._output_filename,
"图片文件 (*.jpg *.png *.webp)"
)
if save_path:
try:
with open(save_path, 'wb') as f:
f.write(self._processed_data)
QMessageBox.information(self, "成功", f"文件已保存到:\n{save_path}")
self.save_requested.emit(save_path)
except Exception as e:
QMessageBox.critical(self, "错误", f"保存失败:\n{e}")
def clear(self):
"""清空预览"""
self.original_preview.clear()
self.result_preview.clear()
self._processed_data = None
self._output_filename = ""
self.save_btn.setEnabled(False)
self.compare_label.setVisible(False)
@staticmethod
def _format_size(size: int) -> str:
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"

242
ui/log_viewer.py Normal file
View File

@@ -0,0 +1,242 @@
"""
日志查看器界面
- 日志文件列表
- 日志内容显示
- 搜索过滤
- 导出功能
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QTextEdit,
QSplitter, QFrame, QFileDialog, QMessageBox
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from pathlib import Path
import logging
from core.logger import get_all_log_files, read_log_file
class LogViewer(QWidget):
"""日志查看器 - 可嵌入设置页面"""
def __init__(self, parent=None):
super().__init__(parent)
self.current_log_content = ""
self.setup_ui()
self.load_log_files()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
# 主内容区 - 分割器
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setStyleSheet("QSplitter::handle { background: #334155; }")
# 左侧 - 日志文件列表
left_panel = QFrame()
left_panel.setStyleSheet("background: rgba(30, 41, 59, 0.5); border-radius: 12px;")
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(12, 12, 12, 12)
left_layout.setSpacing(8)
list_title = QLabel("日志文件")
list_title.setStyleSheet("color: #94a3b8; font-size: 12px; font-weight: 600;")
left_layout.addWidget(list_title)
self.file_list = QListWidget()
self.file_list.setMinimumWidth(180)
self.file_list.currentItemChanged.connect(self.on_file_selected)
left_layout.addWidget(self.file_list)
# 刷新按钮
refresh_btn = QPushButton("🔄 刷新")
refresh_btn.setObjectName("secondary_btn")
refresh_btn.clicked.connect(self.load_log_files)
left_layout.addWidget(refresh_btn)
splitter.addWidget(left_panel)
# 右侧 - 日志内容
right_panel = QFrame()
right_panel.setStyleSheet("background: rgba(30, 41, 59, 0.5); border-radius: 12px;")
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(12, 12, 12, 12)
right_layout.setSpacing(8)
# 搜索和操作栏
toolbar = QHBoxLayout()
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("🔍 搜索日志内容...")
self.search_input.textChanged.connect(self.filter_log)
toolbar.addWidget(self.search_input, 1)
# 级别过滤
self.level_buttons = {}
for level, color in [("ERROR", "#ef4444"), ("WARNING", "#f59e0b"), ("INFO", "#22c55e"), ("DEBUG", "#64748b")]:
btn = QPushButton(level)
btn.setCheckable(True)
btn.setChecked(True)
btn.setStyleSheet(f"""
QPushButton {{
background: rgba({self.hex_to_rgb(color)}, 0.2);
border: 1px solid {color};
color: {color};
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
}}
QPushButton:checked {{
background: {color};
color: white;
}}
""")
btn.clicked.connect(self.filter_log)
toolbar.addWidget(btn)
self.level_buttons[level] = btn
right_layout.addLayout(toolbar)
# 日志内容显示
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setFont(QFont("Consolas", 11))
self.log_text.setStyleSheet("""
QTextEdit {
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 8px;
color: #e2e8f0;
}
""")
right_layout.addWidget(self.log_text, 1)
# 底部操作
bottom_bar = QHBoxLayout()
self.status_label = QLabel("选择左侧日志文件查看")
self.status_label.setStyleSheet("color: #64748b; font-size: 12px;")
bottom_bar.addWidget(self.status_label)
bottom_bar.addStretch()
export_btn = QPushButton("📤 导出日志")
export_btn.setObjectName("primary_btn")
export_btn.clicked.connect(self.export_log)
bottom_bar.addWidget(export_btn)
right_layout.addLayout(bottom_bar)
splitter.addWidget(right_panel)
splitter.setSizes([200, 600])
layout.addWidget(splitter, 1)
def load_log_files(self):
"""加载日志文件列表"""
self.file_list.clear()
log_files = get_all_log_files()
for log_file in log_files:
item = QListWidgetItem(log_file.name)
item.setData(Qt.ItemDataRole.UserRole, log_file)
self.file_list.addItem(item)
# 自动选择第一个(最新)
if self.file_list.count() > 0:
self.file_list.setCurrentRow(0)
logging.info(f"加载了 {len(log_files)} 个日志文件")
def on_file_selected(self, current: QListWidgetItem, previous: QListWidgetItem):
"""日志文件选择变化"""
if current:
file_path = current.data(Qt.ItemDataRole.UserRole)
self.current_log_content = read_log_file(file_path)
self.filter_log()
self.status_label.setText(f"文件: {file_path.name}")
def filter_log(self):
"""过滤日志内容"""
if not self.current_log_content:
return
search_text = self.search_input.text().lower()
active_levels = [level for level, btn in self.level_buttons.items() if btn.isChecked()]
lines = self.current_log_content.split('\n')
filtered_lines = []
for line in lines:
# 检查级别过滤
level_match = any(f"[{level}]" in line for level in active_levels)
if not level_match and line.strip():
# 如果不是日志行(没有级别标记),检查是否是错误堆栈的一部分
if filtered_lines and not line.startswith('['):
level_match = True
else:
continue
# 检查搜索过滤
if search_text and search_text not in line.lower():
continue
filtered_lines.append(line)
# 语法高亮
highlighted = self.highlight_log('\n'.join(filtered_lines))
self.log_text.setHtml(highlighted)
def highlight_log(self, text: str) -> str:
"""日志语法高亮"""
import html
text = html.escape(text)
# 替换颜色
text = text.replace('[ERROR]', '<span style="color: #ef4444; font-weight: bold;">[ERROR]</span>')
text = text.replace('[WARNING]', '<span style="color: #f59e0b; font-weight: bold;">[WARNING]</span>')
text = text.replace('[INFO]', '<span style="color: #22c55e;">[INFO]</span>')
text = text.replace('[DEBUG]', '<span style="color: #64748b;">[DEBUG]</span>')
# 时间戳颜色
import re
text = re.sub(
r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]',
r'<span style="color: #94a3b8;">[\1]</span>',
text
)
return f'<pre style="margin: 0; font-family: Consolas, monospace;">{text}</pre>'
def export_log(self):
"""导出日志文件"""
if not self.current_log_content:
QMessageBox.warning(self, "提示", "请先选择一个日志文件")
return
file_path, _ = QFileDialog.getSaveFileName(
self, "导出日志", "log_export.txt", "文本文件 (*.txt)"
)
if file_path:
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(self.log_text.toPlainText())
QMessageBox.information(self, "成功", f"日志已导出到:\n{file_path}")
logging.info(f"日志已导出到: {file_path}")
except Exception as e:
QMessageBox.critical(self, "错误", f"导出失败: {e}")
logging.error(f"导出日志失败: {e}")
@staticmethod
def hex_to_rgb(hex_color: str) -> str:
"""十六进制颜色转RGB"""
hex_color = hex_color.lstrip('#')
r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
return f"{r}, {g}, {b}"

189
ui/main_window.py Normal file
View File

@@ -0,0 +1,189 @@
"""
主窗口 - 三栏布局
- 左侧: 一级菜单(80px)
- 中间: 二级菜单(256px)
- 右侧: 工作区(弹性宽度)
"""
from PySide6.QtWidgets import (
QMainWindow, QWidget, QHBoxLayout, QStackedWidget, QGraphicsOpacityEffect
)
from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve
from PySide6.QtGui import QFont
import logging
from ui.sidebar import PrimarySidebar
from ui.tool_list import SecondarySidebar, TOOLS_DATA
from ui.workspace import WelcomeWorkspace
from ui.settings import SettingsPage
from ui.animations import animate_widget_show
from ui.profile_card import ProfileCard
from core.config import config
class MainWindow(QMainWindow):
"""主窗口"""
def __init__(self):
super().__init__()
self.setWindowTitle("奶酪云工具箱 | Cheese Cloud Tools")
self.setMinimumSize(1200, 700)
self.resize(1400, 800)
# 工具页面映射
self.tool_pages = {}
self.setup_ui()
self.setup_connections()
logging.info("主窗口初始化完成")
def setup_ui(self):
"""设置UI"""
# 中央部件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
main_layout = QHBoxLayout(central_widget)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# 1. 左侧一级菜单
self.primary_sidebar = PrimarySidebar()
main_layout.addWidget(self.primary_sidebar)
# 2. 中间二级菜单
self.secondary_sidebar = SecondarySidebar()
main_layout.addWidget(self.secondary_sidebar)
# 3. 右侧工作区(使用堆叠窗口切换不同工具页面)
self.workspace_stack = QStackedWidget()
self.workspace_stack.setObjectName("workspace")
main_layout.addWidget(self.workspace_stack, 1)
# 添加欢迎页
self.welcome_page = WelcomeWorkspace()
self.workspace_stack.addWidget(self.welcome_page)
# 添加设置页面
self.settings_page = SettingsPage()
self.workspace_stack.addWidget(self.settings_page)
# 延迟导入并添加工具页面
self.init_tool_pages()
def init_tool_pages(self):
"""初始化所有工具页面"""
# 图片工具
from tools.image.compress import ImageCompressPage
from tools.image.convert import ImageConvertPage
from tools.image.watermark import ImageWatermarkPage
self.tool_pages["img-compress"] = ImageCompressPage()
self.tool_pages["img-convert"] = ImageConvertPage()
self.tool_pages["img-watermark"] = ImageWatermarkPage()
# PDF工具
from tools.pdf.split import PDFSplitPage
from tools.pdf.merge import PDFMergePage
from tools.pdf.to_word import PDFToWordPage
self.tool_pages["pdf-split"] = PDFSplitPage()
self.tool_pages["pdf-merge"] = PDFMergePage()
self.tool_pages["pdf-word"] = PDFToWordPage()
# Excel工具
from tools.excel.preview import ExcelPreviewPage
from tools.excel.chart import ExcelChartPage
self.tool_pages["xls-view"] = ExcelPreviewPage()
self.tool_pages["xls-chart"] = ExcelChartPage()
# 添加到堆叠窗口
for page in self.tool_pages.values():
self.workspace_stack.addWidget(page)
def setup_connections(self):
"""设置信号连接"""
# 一级菜单切换
self.primary_sidebar.category_changed.connect(self.on_category_changed)
self.primary_sidebar.settings_clicked.connect(self.show_settings)
self.primary_sidebar.logo_clicked.connect(self.show_profile_card)
# 二级菜单工具选择
self.secondary_sidebar.tool_selected.connect(self.on_tool_selected)
# 欢迎页快捷入口
self.welcome_page.tool_clicked.connect(self.on_shortcut_clicked)
def on_category_changed(self, category: str):
"""分类切换"""
logging.debug(f"切换分类: {category}")
self.secondary_sidebar.load_tools(category)
# 显示欢迎页
self._switch_page(self.welcome_page)
def on_tool_selected(self, tool_data: dict):
"""工具选择"""
tool_id = tool_data.get("id")
logging.info(f"选择工具: {tool_data.get('name')} ({tool_id})")
if tool_id in self.tool_pages:
page = self.tool_pages[tool_id]
# 设置面包屑
category_title = TOOLS_DATA.get(self.secondary_sidebar.current_category, {}).get("title", "")
page.set_breadcrumb(category_title, tool_data.get("name", ""))
page.set_title(tool_data.get("name", ""))
self._switch_page(page)
else:
logging.warning(f"未找到工具页面: {tool_id}")
def on_shortcut_clicked(self, tool_id: str, category: str):
"""快捷入口点击"""
logging.debug(f"快捷入口: {tool_id}, {category}")
self.primary_sidebar.set_category(category)
self.secondary_sidebar.load_tools(category)
self.secondary_sidebar.select_tool(tool_id)
def show_settings(self):
"""显示设置页面"""
logging.debug("打开设置页面")
self._switch_page(self.settings_page)
def show_profile_card(self):
"""显示个人信息卡片"""
logging.debug("打开个人信息卡片")
dialog = ProfileCard(self)
dialog.exec()
def _switch_page(self, page: QWidget):
"""切换页面(带动画)"""
if not config.get("animation_enabled", True):
self.workspace_stack.setCurrentWidget(page)
return
# 获取当前页面
current = self.workspace_stack.currentWidget()
if current == page:
return
# 设置新页面透明度效果
effect = QGraphicsOpacityEffect(page)
effect.setOpacity(0)
page.setGraphicsEffect(effect)
# 切换页面
self.workspace_stack.setCurrentWidget(page)
# 渐入动画
duration = config.get("animation_duration", 300)
anim = QPropertyAnimation(effect, b"opacity")
anim.setDuration(duration)
anim.setStartValue(0.0)
anim.setEndValue(1.0)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
# 保持引用防止被垃圾回收
page._fade_anim = anim
anim.start()

539
ui/profile_card.py Normal file
View File

@@ -0,0 +1,539 @@
"""
个人信息卡片模态框
- 头像、微信二维码、邮箱
- 高级UI设计,带装饰元素和动画
"""
import os
from pathlib import Path
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QWidget, QGraphicsOpacityEffect, QApplication
)
from PySide6.QtCore import (
Qt, QPropertyAnimation, QEasingCurve, QTimer,
QPoint, QRect, QParallelAnimationGroup, Signal
)
from PySide6.QtGui import (
QPixmap, QPainter, QColor, QLinearGradient, QRadialGradient,
QFont, QPen, QBrush, QPainterPath, QCursor
)
import math
def get_resource_path(relative_path: str) -> str:
"""获取资源文件的绝对路径"""
base_path = Path(__file__).parent.parent
return str(base_path / relative_path)
# 资源路径
AVATAR_PATH = get_resource_path("image/头像.jpg")
QRCODE_PATH = get_resource_path("image/二维码.jpg")
class FloatingOrb(QWidget):
"""浮动装饰球"""
def __init__(self, size: int, color: QColor, parent=None):
super().__init__(parent)
self.orb_size = size
self.orb_color = color
self.setFixedSize(size, size)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
# 设置透明效果
self.opacity_effect = QGraphicsOpacityEffect(self)
self.opacity_effect.setOpacity(0.6)
self.setGraphicsEffect(self.opacity_effect)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# 径向渐变
gradient = QRadialGradient(
self.orb_size / 2, self.orb_size / 2, self.orb_size / 2
)
gradient.setColorAt(0, QColor(self.orb_color.red(), self.orb_color.green(), self.orb_color.blue(), 120))
gradient.setColorAt(0.5, QColor(self.orb_color.red(), self.orb_color.green(), self.orb_color.blue(), 60))
gradient.setColorAt(1, QColor(self.orb_color.red(), self.orb_color.green(), self.orb_color.blue(), 0))
painter.setBrush(gradient)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(0, 0, self.orb_size, self.orb_size)
class GlowButton(QPushButton):
"""发光按钮"""
def __init__(self, text: str, primary: bool = True, parent=None):
super().__init__(text, parent)
self.primary = primary
self._hovered = False
self.setFixedHeight(44)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFont(QFont("Microsoft YaHei UI", 10, QFont.Weight.Medium))
self.setStyleSheet("""
QPushButton {
border: none;
border-radius: 10px;
padding: 10px 24px;
color: white;
}
""")
def enterEvent(self, event):
self._hovered = True
self.update()
def leaveEvent(self, event):
self._hovered = False
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.rect()
if self.primary:
# 主按钮 - 奶酪色渐变
gradient = QLinearGradient(0, 0, rect.width(), 0)
if self._hovered:
gradient.setColorAt(0, QColor("#fcd34d"))
gradient.setColorAt(1, QColor("#f59e0b"))
else:
gradient.setColorAt(0, QColor("#fbbf24"))
gradient.setColorAt(1, QColor("#d97706"))
painter.setBrush(gradient)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(rect, 10, 10)
# 发光效果
if self._hovered:
glow = QRadialGradient(rect.center().x(), rect.center().y(), rect.width() / 2)
glow.setColorAt(0, QColor(251, 191, 36, 60))
glow.setColorAt(1, QColor(251, 191, 36, 0))
painter.setBrush(glow)
painter.drawRoundedRect(rect.adjusted(-5, -5, 5, 5), 15, 15)
else:
# 次按钮 - 透明边框
if self._hovered:
painter.setBrush(QColor(71, 85, 105, 80))
else:
painter.setBrush(QColor(71, 85, 105, 40))
painter.setPen(QPen(QColor("#475569"), 1))
painter.drawRoundedRect(rect.adjusted(1, 1, -1, -1), 10, 10)
# 绘制文字
painter.setPen(QColor("white") if self.primary else QColor("#94a3b8"))
painter.setFont(self.font())
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self.text())
class ProfileCard(QDialog):
"""个人信息卡片模态框"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.Dialog
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setModal(True)
self.setFixedSize(480, 780)
self._drag_pos = None
self.setup_ui()
self.setup_animations()
self.add_decorations()
def setup_ui(self):
"""设置UI"""
# 主容器
self.container = QWidget(self)
self.container.setGeometry(0, 0, 480, 780)
main_layout = QVBoxLayout(self.container)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# 内容卡片
self.card = QWidget()
self.card.setObjectName("profile_card")
card_layout = QVBoxLayout(self.card)
card_layout.setContentsMargins(32, 24, 32, 24)
card_layout.setSpacing(12)
# 关闭按钮
close_btn = QPushButton("×")
close_btn.setFixedSize(36, 36)
close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
close_btn.setStyleSheet("""
QPushButton {
background: rgba(71, 85, 105, 0.4);
border: none;
border-radius: 18px;
color: #94a3b8;
font-size: 20px;
font-weight: bold;
}
QPushButton:hover {
background: rgba(239, 68, 68, 0.6);
color: white;
}
""")
close_btn.clicked.connect(self.close_with_animation)
close_layout = QHBoxLayout()
close_layout.addStretch()
close_layout.addWidget(close_btn)
card_layout.addLayout(close_layout)
# 头像区域
avatar_container = QWidget()
avatar_container.setFixedHeight(100)
avatar_layout = QHBoxLayout(avatar_container)
avatar_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.avatar_label = QLabel()
self.avatar_label.setFixedSize(100, 100)
self.avatar_label.setStyleSheet("""
QLabel {
border-radius: 50px;
border: 3px solid #fbbf24;
}
""")
self._load_avatar()
avatar_layout.addWidget(self.avatar_label)
card_layout.addWidget(avatar_container)
# 名称
name_label = QLabel("奶酪云工具箱")
name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
name_label.setStyleSheet("""
QLabel {
color: #f8fafc;
font-size: 22px;
font-weight: bold;
font-family: "Microsoft YaHei UI";
}
""")
card_layout.addWidget(name_label)
# 副标题
subtitle_label = QLabel("高效办公 · 精致生活")
subtitle_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
subtitle_label.setStyleSheet("""
QLabel {
color: #64748b;
font-size: 13px;
font-family: "Microsoft YaHei UI";
}
""")
card_layout.addWidget(subtitle_label)
card_layout.addSpacing(8)
# 分割线
divider = QWidget()
divider.setFixedHeight(1)
divider.setStyleSheet("background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 transparent, stop:0.5 #334155, stop:1 transparent);")
card_layout.addWidget(divider)
card_layout.addSpacing(8)
# 二维码区域
qr_container = QWidget()
qr_layout = QVBoxLayout(qr_container)
qr_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
qr_layout.setSpacing(8)
qr_title = QLabel("📱 微信扫码添加好友")
qr_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
qr_title.setStyleSheet("""
QLabel {
color: #94a3b8;
font-size: 12px;
font-family: "Microsoft YaHei UI";
}
""")
qr_layout.addWidget(qr_title)
self.qr_label = QLabel()
self.qr_label.setFixedSize(220, 220)
self.qr_label.setStyleSheet("""
QLabel {
background: white;
border-radius: 12px;
}
""")
self._load_qrcode()
qr_layout.addWidget(self.qr_label, 0, Qt.AlignmentFlag.AlignCenter)
card_layout.addWidget(qr_container)
card_layout.addSpacing(10)
# 邮箱区域
email_container = QWidget()
email_container.setStyleSheet("""
QWidget {
background: rgba(30, 41, 59, 0.6);
border-radius: 12px;
}
""")
email_layout = QVBoxLayout(email_container)
email_layout.setContentsMargins(20, 16, 20, 16)
email_layout.setSpacing(8)
email_title = QLabel("📧 合作联系邮箱")
email_title.setStyleSheet("""
QLabel {
color: #94a3b8;
font-size: 12px;
background: transparent;
}
""")
email_layout.addWidget(email_title)
email_row = QHBoxLayout()
self.email_label = QLabel("workerqi@163.com")
self.email_label.setStyleSheet("""
QLabel {
color: #fbbf24;
font-size: 16px;
font-weight: bold;
background: transparent;
}
""")
email_row.addWidget(self.email_label)
email_row.addStretch()
copy_btn = GlowButton("复制", False)
copy_btn.setFixedSize(70, 34)
copy_btn.clicked.connect(self.copy_email)
email_row.addWidget(copy_btn)
send_btn = GlowButton("发送邮件", True)
send_btn.setFixedSize(90, 34)
send_btn.clicked.connect(self.send_email)
email_row.addWidget(send_btn)
email_layout.addLayout(email_row)
card_layout.addWidget(email_container)
card_layout.addStretch()
# 底部提示
tip_label = QLabel("点击空白处关闭")
tip_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
tip_label.setStyleSheet("""
QLabel {
color: #475569;
font-size: 11px;
}
""")
card_layout.addWidget(tip_label)
main_layout.addWidget(self.card)
def _load_avatar(self):
"""加载头像"""
if os.path.exists(AVATAR_PATH):
pixmap = QPixmap(AVATAR_PATH)
if not pixmap.isNull():
# 创建圆形头像
scaled = pixmap.scaled(94, 94, Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation)
# 裁剪为圆形
rounded = QPixmap(94, 94)
rounded.fill(Qt.GlobalColor.transparent)
painter = QPainter(rounded)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
path = QPainterPath()
path.addEllipse(0, 0, 94, 94)
painter.setClipPath(path)
# 居中裁剪
x = (scaled.width() - 94) // 2
y = (scaled.height() - 94) // 2
painter.drawPixmap(0, 0, scaled, x, y, 94, 94)
painter.end()
self.avatar_label.setPixmap(rounded)
return
# 默认头像
self.avatar_label.setText("🧀")
self.avatar_label.setStyleSheet(self.avatar_label.styleSheet() + """
font-size: 48px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #fbbf24, stop:1 #d97706);
""")
def _load_qrcode(self):
"""加载二维码"""
if os.path.exists(QRCODE_PATH):
pixmap = QPixmap(QRCODE_PATH)
if not pixmap.isNull():
# 缩放到完整显示,保持宽高比
scaled = pixmap.scaled(210, 210, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
self.qr_label.setPixmap(scaled)
self.qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
return
self.qr_label.setText("二维码")
self.qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
def add_decorations(self):
"""添加装饰元素"""
# 添加浮动装饰球
orb1 = FloatingOrb(120, QColor("#fbbf24"), self.container)
orb1.move(-30, -30)
orb2 = FloatingOrb(80, QColor("#3b82f6"), self.container)
orb2.move(420, 50)
orb3 = FloatingOrb(60, QColor("#8b5cf6"), self.container)
orb3.move(400, 500)
orb4 = FloatingOrb(100, QColor("#ec4899"), self.container)
orb4.move(-20, 480)
# 确保装饰在底层
orb1.lower()
orb2.lower()
orb3.lower()
orb4.lower()
def setup_animations(self):
"""设置动画"""
# 卡片淡入效果
self.card_opacity = QGraphicsOpacityEffect(self.card)
self.card_opacity.setOpacity(0)
self.card.setGraphicsEffect(self.card_opacity)
self.fade_anim = QPropertyAnimation(self.card_opacity, b"opacity")
self.fade_anim.setDuration(300)
self.fade_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
def showEvent(self, event):
super().showEvent(event)
# 居中显示
if self.parent():
parent_rect = self.parent().rect()
x = (parent_rect.width() - self.width()) // 2
y = (parent_rect.height() - self.height()) // 2
self.move(self.parent().mapToGlobal(QPoint(x, y)))
# 播放淡入动画
self.fade_anim.setStartValue(0)
self.fade_anim.setEndValue(1)
self.fade_anim.start()
def close_with_animation(self):
"""带动画关闭"""
self.fade_anim.setStartValue(1)
self.fade_anim.setEndValue(0)
self.fade_anim.finished.connect(self.accept)
self.fade_anim.start()
def copy_email(self):
"""复制邮箱"""
clipboard = QApplication.clipboard()
clipboard.setText("workerqi@163.com")
# 临时显示"已复制"
original_text = self.email_label.text()
self.email_label.setText("✓ 已复制到剪贴板")
self.email_label.setStyleSheet("""
QLabel {
color: #22c55e;
font-size: 16px;
font-weight: bold;
background: transparent;
}
""")
QTimer.singleShot(1500, lambda: self._reset_email_label(original_text))
def _reset_email_label(self, text):
"""重置邮箱标签"""
self.email_label.setText(text)
self.email_label.setStyleSheet("""
QLabel {
color: #fbbf24;
font-size: 16px;
font-weight: bold;
background: transparent;
}
""")
def send_email(self):
"""发送邮件"""
import webbrowser
webbrowser.open("mailto:workerqi@163.com?subject=奶酪云工具箱-合作咨询")
def paintEvent(self, event):
"""绘制背景"""
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# 半透明遮罩背景
painter.fillRect(self.rect(), QColor(0, 0, 0, 120))
# 卡片背景
card_rect = self.card.geometry().adjusted(20, 20, -20, -20)
# 卡片阴影
shadow_color = QColor(0, 0, 0, 80)
for i in range(10):
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(0, 0, 0, int(80 - i * 8)))
painter.drawRoundedRect(card_rect.adjusted(-i*2, -i*2, i*2, i*2), 24 + i, 24 + i)
# 卡片主体 - 玻璃质感
gradient = QLinearGradient(card_rect.topLeft(), card_rect.bottomRight())
gradient.setColorAt(0, QColor(30, 41, 59, 240))
gradient.setColorAt(0.5, QColor(15, 23, 42, 250))
gradient.setColorAt(1, QColor(30, 41, 59, 240))
painter.setBrush(gradient)
painter.setPen(QPen(QColor(71, 85, 105, 100), 1))
painter.drawRoundedRect(card_rect, 24, 24)
# 顶部高光
highlight_rect = QRect(card_rect.x() + 40, card_rect.y(), card_rect.width() - 80, 2)
highlight_gradient = QLinearGradient(highlight_rect.topLeft(), highlight_rect.topRight())
highlight_gradient.setColorAt(0, QColor(251, 191, 36, 0))
highlight_gradient.setColorAt(0.5, QColor(251, 191, 36, 150))
highlight_gradient.setColorAt(1, QColor(251, 191, 36, 0))
painter.setBrush(highlight_gradient)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(highlight_rect, 1, 1)
def mousePressEvent(self, event):
"""点击空白处关闭"""
card_rect = self.card.geometry().adjusted(20, 20, -20, -20)
if not card_rect.contains(event.pos()):
self.close_with_animation()
else:
self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
def mouseMoveEvent(self, event):
"""拖动窗口"""
if self._drag_pos:
self.move(event.globalPosition().toPoint() - self._drag_pos)
def mouseReleaseEvent(self, event):
self._drag_pos = None

234
ui/settings.py Normal file
View File

@@ -0,0 +1,234 @@
"""
设置页面
- 全局配置选项
- 文件保存位置设置
- 日志查看器
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFrame, QLineEdit, QFileDialog, QCheckBox, QSlider,
QTabWidget, QSpinBox, QMessageBox, QScrollArea, QGroupBox
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
import logging
from core.config import config
from ui.log_viewer import LogViewer
class SettingsPage(QWidget):
"""设置页面"""
def __init__(self, parent=None):
super().__init__(parent)
self.setup_ui()
self.load_settings()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# 标题
title = QLabel("⚙️ 设置")
title.setFont(QFont("Microsoft YaHei", 20, QFont.Weight.Bold))
title.setStyleSheet("color: white;")
layout.addWidget(title)
# 标签页
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet("""
QTabWidget::pane {
border: none;
background: transparent;
}
QTabBar::tab {
background: #1e293b;
color: #94a3b8;
padding: 12px 24px;
border: none;
border-bottom: 2px solid transparent;
font-size: 13px;
}
QTabBar::tab:hover {
color: #e2e8f0;
}
QTabBar::tab:selected {
color: #fbbf24;
border-bottom: 2px solid #fbbf24;
}
""")
# 通用设置标签页
self.tab_widget.addTab(self.create_general_tab(), "📁 通用设置")
# 界面设置标签页
self.tab_widget.addTab(self.create_ui_tab(), "🎨 界面设置")
# 日志查看标签页
self.log_viewer = LogViewer()
self.tab_widget.addTab(self.log_viewer, "📋 日志查看")
layout.addWidget(self.tab_widget, 1)
# 底部按钮
btn_layout = QHBoxLayout()
btn_layout.addStretch()
reset_btn = QPushButton("🔄 恢复默认")
reset_btn.setObjectName("secondary_btn")
reset_btn.clicked.connect(self.reset_settings)
btn_layout.addWidget(reset_btn)
save_btn = QPushButton("💾 保存设置")
save_btn.setObjectName("primary_btn")
save_btn.clicked.connect(self.save_settings)
btn_layout.addWidget(save_btn)
layout.addLayout(btn_layout)
def create_general_tab(self) -> QWidget:
"""创建通用设置标签页"""
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(20)
# 输出目录设置
output_group = QGroupBox("📂 默认保存位置")
output_layout = QVBoxLayout(output_group)
output_layout.setSpacing(12)
# 路径选择
path_layout = QHBoxLayout()
self.output_path_edit = QLineEdit()
self.output_path_edit.setPlaceholderText("未设置 - 每次操作时询问保存位置")
self.output_path_edit.setReadOnly(True)
self.output_path_edit.setStyleSheet("""
QLineEdit {
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 10px 12px;
color: #e2e8f0;
font-size: 13px;
}
""")
path_layout.addWidget(self.output_path_edit, 1)
browse_btn = QPushButton("📁 浏览")
browse_btn.setObjectName("secondary_btn")
browse_btn.clicked.connect(self.browse_output_dir)
path_layout.addWidget(browse_btn)
clear_btn = QPushButton("✖ 清除")
clear_btn.setObjectName("secondary_btn")
clear_btn.clicked.connect(self.clear_output_dir)
path_layout.addWidget(clear_btn)
output_layout.addLayout(path_layout)
# 自动保存选项
self.auto_save_check = QCheckBox("自动保存到默认目录(不再询问)")
self.auto_save_check.setStyleSheet("color: #cbd5e1; font-size: 13px;")
output_layout.addWidget(self.auto_save_check)
hint = QLabel("提示: 设置默认保存位置后,处理完成的文件将自动保存到该目录")
hint.setStyleSheet("color: #64748b; font-size: 11px;")
hint.setWordWrap(True)
output_layout.addWidget(hint)
layout.addWidget(output_group)
layout.addStretch()
return widget
def create_ui_tab(self) -> QWidget:
"""创建界面设置标签页"""
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(20)
# 动画设置
anim_group = QGroupBox("✨ 动画效果")
anim_layout = QVBoxLayout(anim_group)
anim_layout.setSpacing(12)
self.animation_check = QCheckBox("启用界面动画(渐入渐出效果)")
self.animation_check.setStyleSheet("color: #cbd5e1; font-size: 13px;")
self.animation_check.setChecked(True)
self.animation_check.stateChanged.connect(self.on_animation_toggle)
anim_layout.addWidget(self.animation_check)
# 动画时长
duration_row = QHBoxLayout()
duration_row.addWidget(QLabel("动画时长:"))
duration_row.addStretch()
self.duration_spin = QSpinBox()
self.duration_spin.setRange(100, 1000)
self.duration_spin.setValue(300)
self.duration_spin.setSuffix(" ms")
self.duration_spin.setFixedWidth(100)
duration_row.addWidget(self.duration_spin)
anim_layout.addLayout(duration_row)
layout.addWidget(anim_group)
layout.addStretch()
return widget
def browse_output_dir(self):
"""浏览输出目录"""
current = self.output_path_edit.text() or ""
path = QFileDialog.getExistingDirectory(self, "选择默认保存目录", current)
if path:
self.output_path_edit.setText(path)
def clear_output_dir(self):
"""清除输出目录"""
self.output_path_edit.setText("")
self.auto_save_check.setChecked(False)
def on_animation_toggle(self, state):
"""动画开关切换"""
self.duration_spin.setEnabled(state == Qt.CheckState.Checked.value)
def load_settings(self):
"""加载设置"""
self.output_path_edit.setText(config.get("output_directory", ""))
self.auto_save_check.setChecked(config.get("auto_save_to_default", False))
self.animation_check.setChecked(config.get("animation_enabled", True))
self.duration_spin.setValue(config.get("animation_duration", 300))
self.duration_spin.setEnabled(config.get("animation_enabled", True))
def save_settings(self):
"""保存设置"""
config.set("output_directory", self.output_path_edit.text())
config.set("auto_save_to_default", self.auto_save_check.isChecked())
config.set("animation_enabled", self.animation_check.isChecked())
config.set("animation_duration", self.duration_spin.value())
QMessageBox.information(self, "成功", "设置已保存!")
logging.info("用户设置已保存")
def reset_settings(self):
"""恢复默认设置"""
reply = QMessageBox.question(
self, "确认", "确定要恢复默认设置吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
for key, value in config.DEFAULT_CONFIG.items():
config.set(key, value)
self.load_settings()
QMessageBox.information(self, "成功", "已恢复默认设置!")
logging.info("用户设置已恢复默认")

471
ui/sidebar.py Normal file
View File

@@ -0,0 +1,471 @@
"""
左侧一级菜单组件
- Logo (使用图片,点击弹出个人信息卡片)
- 图片工具、PDF工具箱、Excel表格 三个分类
- 设置按钮
- 选中状态有左侧高亮条
- 水波纹点击效果
"""
import os
from pathlib import Path
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QButtonGroup,
QSpacerItem, QSizePolicy, QLabel, QFrame, QGraphicsOpacityEffect
)
from PySide6.QtCore import (
Signal, Qt, QSize, QPropertyAnimation, QEasingCurve,
QPoint, QTimer, QParallelAnimationGroup, QSequentialAnimationGroup,
Property
)
from PySide6.QtGui import (
QFont, QPainter, QColor, QLinearGradient, QPen, QBrush, QPixmap,
QRadialGradient, QPainterPath
)
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtCore import QByteArray
# 获取资源目录
def get_resource_path(relative_path: str) -> str:
"""获取资源文件的绝对路径"""
base_path = Path(__file__).parent.parent
return str(base_path / relative_path)
# Logo图片路径
LOGO_IMAGE_PATH = get_resource_path("image/生成奶酪商城官方店介绍.png")
# SVG 图标定义 (Phosphor Icons 风格)
ICONS = {
"cheese": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M184 32a8 8 0 0 0-8 8v16h-16a8 8 0 0 0 0 16h16v16a8 8 0 0 0 16 0V72h16a8 8 0 0 0 0-16h-16V40a8 8 0 0 0-8-8m48 88h-64a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-64a8 8 0 0 0-8-8m-8 64h-48v-48h48ZM80 40a8 8 0 0 0-8 8v16H56a8 8 0 0 0 0 16h16v16a8 8 0 0 0 16 0V80h16a8 8 0 0 0 0-16H88V48a8 8 0 0 0-8-8m48 88H64a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-64a8 8 0 0 0-8-8m-8 64H72v-48h48Z"/></svg>""",
"image": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38V200m-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></svg>""",
"file-pdf": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M224 152a8 8 0 0 1-8 8h-24v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0v-56a8 8 0 0 1 8-8h32a8 8 0 0 1 8 8M92 172a28 28 0 0 1-28 28h-8v8a8 8 0 0 1-16 0v-56a8 8 0 0 1 8-8h16a28 28 0 0 1 28 28m-16 0a12 12 0 0 0-12-12h-8v24h8a12 12 0 0 0 12-12m88 0c0 21.78-14.36 36-36 36h-16a8 8 0 0 1-8-8v-56a8 8 0 0 1 8-8h16c21.64 0 36 14.22 36 36m-16 0c0-13.56-8-20-20-20h-8v40h8c12 0 20-6.44 20-20M40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 0 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.69L160 51.31Z"/></svg>""",
"file-xls": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M156 208a8 8 0 0 1-8 8h-24a8 8 0 0 1-8-8v-56a8 8 0 0 1 16 0v48h16a8 8 0 0 1 8 8m-62.37-55.58a8 8 0 0 0-11.26 1.21L68 172.32l-14.37-18.69a8 8 0 0 0-12.47 10.05L58.59 184l-17.43 20.32a8 8 0 0 0 12.47 10.05L68 195.68l14.37 18.69A8 8 0 0 0 88 216a8 8 0 0 0 5.05-1.79a8 8 0 0 0 1.21-11.26L77.41 184l16.85-19.63a8 8 0 0 0-1.63-11.95m72 5.58c-14.06 0-24.63 8.88-24.63 20.67S151.57 200 166.63 200a22.76 22.76 0 0 0 7.47-1.27a8 8 0 0 0-5.2-15.13a6.89 6.89 0 0 1-2.27.4c-4.39 0-8.63-2.72-8.63-4.67s4.24-4.66 8.63-4.66c3.36 0 5.15 1 5.79 1.55a8 8 0 0 0 10.63-12a22.6 22.6 0 0 0-16.42-5.89M40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 0 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.69L160 51.31Z"/></svg>""",
"gear": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.29 107.29 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.71 107.71 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.71 107.71 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.21 107.21 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.71 107.71 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a73.93 73.93 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.57 91.57 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74.11 74.11 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.32 91.32 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a73.93 73.93 0 0 1-8.68 0a8 8 0 0 0-5.48 1.74l-17.78 14.2a91.57 91.57 0 0 1-15-6.23L82.89 168a8 8 0 0 0-2.64-5.1a74.11 74.11 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.32 91.32 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a73.93 73.93 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 81.76a91.57 91.57 0 0 1 6.23-15L69 64.2a8 8 0 0 0 5.1-2.64a74.11 74.11 0 0 1 6.14-6.14A8 8 0 0 0 82.89 50l2.51-22.57a91.32 91.32 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a73.93 73.93 0 0 1 8.68 0a8 8 0 0 0 5.48-1.74l17.77-14.19a91.57 91.57 0 0 1 15 6.23L168 50a8 8 0 0 0 2.64 5.1a74.11 74.11 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.32 91.32 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.6 5.53Z"/></svg>""",
}
class IconWidget(QLabel):
"""SVG图标组件"""
def __init__(self, icon_name: str, size: int = 24, color: str = "#94a3b8", parent=None):
super().__init__(parent)
self.icon_name = icon_name
self.icon_size = size
self.icon_color = color
self.setFixedSize(size, size)
self.update_icon()
def set_color(self, color: str):
self.icon_color = color
self.update_icon()
def update_icon(self):
svg_data = ICONS.get(self.icon_name, "")
if svg_data:
# 替换颜色
svg_data = svg_data.replace('currentColor', self.icon_color)
renderer = QSvgRenderer(QByteArray(svg_data.encode()))
pixmap = QPixmap(self.icon_size, self.icon_size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
self.setPixmap(pixmap)
class RippleEffect:
"""水波纹效果数据"""
def __init__(self, center: QPoint, max_radius: float):
self.center = center
self.max_radius = max_radius
self.current_radius = 0.0
self.opacity = 0.4
class CategoryButton(QWidget):
"""分类按钮 - 带左侧高亮条和水波纹效果"""
clicked = Signal()
def __init__(self, icon_name: str, tooltip: str, parent=None):
super().__init__(parent)
self.icon_name = icon_name
self._checked = False
self._hovered = False
self._pressed = False
self.setFixedSize(80, 56) # 放大按钮
self.setToolTip(tooltip)
self.setCursor(Qt.CursorShape.PointingHandCursor)
# 水波纹效果
self._ripples = []
self._ripple_timer = QTimer(self)
self._ripple_timer.timeout.connect(self._update_ripples)
# 图标 - 放大
self.icon_widget = IconWidget(icon_name, 28, "#94a3b8", self)
self.icon_widget.move(26, 14)
def setChecked(self, checked: bool):
self._checked = checked
self.update_style()
self.update()
def isChecked(self) -> bool:
return self._checked
def update_style(self):
if self._checked:
self.icon_widget.set_color("#fbbf24")
elif self._hovered:
self.icon_widget.set_color("#fbbf24")
else:
self.icon_widget.set_color("#94a3b8")
def enterEvent(self, event):
self._hovered = True
self.update_style()
self.update()
def leaveEvent(self, event):
self._hovered = False
self.update_style()
self.update()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._pressed = True
# 创建水波纹
self._create_ripple(event.pos())
self.update()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._pressed = False
if self.rect().contains(event.pos()):
self.clicked.emit()
self.update()
def _create_ripple(self, pos: QPoint):
"""创建水波纹效果"""
# 计算最大半径
distances = [
(pos - QPoint(0, 0)).manhattanLength(),
(pos - QPoint(self.width(), 0)).manhattanLength(),
(pos - QPoint(0, self.height())).manhattanLength(),
(pos - QPoint(self.width(), self.height())).manhattanLength()
]
max_radius = max(distances) * 0.8
ripple = RippleEffect(pos, max_radius)
self._ripples.append(ripple)
if not self._ripple_timer.isActive():
self._ripple_timer.start(16) # ~60fps
def _update_ripples(self):
"""更新水波纹动画"""
to_remove = []
for ripple in self._ripples:
ripple.current_radius += ripple.max_radius * 0.08
ripple.opacity -= 0.025
if ripple.opacity <= 0:
to_remove.append(ripple)
for ripple in to_remove:
self._ripples.remove(ripple)
if not self._ripples:
self._ripple_timer.stop()
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# 绘制按钮背景
btn_rect = self.rect().adjusted(12, 4, -12, -4)
if self._checked:
# 选中背景
painter.setBrush(QColor(251, 191, 36, 30)) # cheese-400/20
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(btn_rect, 14, 14)
# 左侧高亮条
indicator_height = int(self.height() * 0.6)
indicator_y = (self.height() - indicator_height) // 2
# 发光效果
glow_color = QColor(251, 191, 36, 80)
painter.setBrush(glow_color)
painter.drawRoundedRect(0, indicator_y - 2, 6, indicator_height + 4, 3, 3)
# 实体条
painter.setBrush(QColor("#fbbf24"))
painter.drawRoundedRect(0, indicator_y, 4, indicator_height, 2, 2)
elif self._hovered:
painter.setBrush(QColor(51, 65, 85, 128)) # darkbg-700/50
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(btn_rect, 14, 14)
# 绘制水波纹
if self._ripples:
painter.save()
# 创建裁剪区域
path = QPainterPath()
path.addRoundedRect(btn_rect.x(), btn_rect.y(), btn_rect.width(), btn_rect.height(), 14, 14)
painter.setClipPath(path)
for ripple in self._ripples:
color = QColor(251, 191, 36, int(ripple.opacity * 255))
gradient = QRadialGradient(ripple.center.x(), ripple.center.y(), ripple.current_radius)
gradient.setColorAt(0, QColor(251, 191, 36, 0))
gradient.setColorAt(0.5, color)
gradient.setColorAt(1, QColor(251, 191, 36, 0))
painter.setBrush(gradient)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(
ripple.center,
int(ripple.current_radius),
int(ripple.current_radius)
)
painter.restore()
class LogoButton(QWidget):
"""Logo按钮 - 使用图片,无背景,带水波纹效果"""
clicked = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(60, 60) # 放大
self.setToolTip("点击查看个人信息")
self.setCursor(Qt.CursorShape.PointingHandCursor)
self._hovered = False
self._pressed = False
self._logo_pixmap = None
self._scale = 1.0
# 水波纹效果
self._ripples = []
self._ripple_timer = QTimer(self)
self._ripple_timer.timeout.connect(self._update_ripples)
# 悬停缩放动画
self._scale_anim = QPropertyAnimation(self, b"scale_factor")
self._scale_anim.setDuration(150)
self._scale_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._load_logo()
def get_scale_factor(self):
return self._scale
def set_scale_factor(self, value):
self._scale = value
self.update()
# 使用 PySide6 Property 以支持动画
scale_factor = Property(float, get_scale_factor, set_scale_factor)
def _load_logo(self):
"""加载Logo图片"""
if os.path.exists(LOGO_IMAGE_PATH):
self._logo_pixmap = QPixmap(LOGO_IMAGE_PATH)
if not self._logo_pixmap.isNull():
# 缩放到合适大小
self._logo_pixmap = self._logo_pixmap.scaled(
56, 56,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
def enterEvent(self, event):
self._hovered = True
self._scale_anim.stop()
self._scale_anim.setStartValue(self._scale)
self._scale_anim.setEndValue(1.1)
self._scale_anim.start()
self.update()
def leaveEvent(self, event):
self._hovered = False
self._scale_anim.stop()
self._scale_anim.setStartValue(self._scale)
self._scale_anim.setEndValue(1.0)
self._scale_anim.start()
self.update()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._pressed = True
self._create_ripple(event.pos())
self.update()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._pressed = False
if self.rect().contains(event.pos()):
self.clicked.emit()
self.update()
def _create_ripple(self, pos: QPoint):
"""创建水波纹效果"""
max_radius = max(self.width(), self.height())
ripple = RippleEffect(pos, max_radius)
self._ripples.append(ripple)
if not self._ripple_timer.isActive():
self._ripple_timer.start(16)
def _update_ripples(self):
"""更新水波纹动画"""
to_remove = []
for ripple in self._ripples:
ripple.current_radius += ripple.max_radius * 0.1
ripple.opacity -= 0.03
if ripple.opacity <= 0:
to_remove.append(ripple)
for ripple in to_remove:
self._ripples.remove(ripple)
if not self._ripples:
self._ripple_timer.stop()
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
center = self.rect().center()
# 绘制Logo图片(无背景)
if self._logo_pixmap and not self._logo_pixmap.isNull():
# 应用缩放
scaled_size = int(self._logo_pixmap.width() * self._scale)
scaled_pixmap = self._logo_pixmap.scaled(
scaled_size, scaled_size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
x = center.x() - scaled_pixmap.width() // 2
y = center.y() - scaled_pixmap.height() // 2
# 悬停时添加发光效果
if self._hovered:
glow_rect = self.rect().adjusted(-4, -4, 4, 4)
glow = QRadialGradient(center.x(), center.y(), 40)
glow.setColorAt(0, QColor(251, 191, 36, 60))
glow.setColorAt(0.7, QColor(251, 191, 36, 30))
glow.setColorAt(1, QColor(251, 191, 36, 0))
painter.setBrush(glow)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(center, 36, 36)
painter.drawPixmap(x, y, scaled_pixmap)
# 绘制水波纹
if self._ripples:
painter.save()
# 圆形裁剪
path = QPainterPath()
path.addEllipse(center.x() - 28, center.y() - 28, 56, 56)
painter.setClipPath(path)
for ripple in self._ripples:
color = QColor(251, 191, 36, int(ripple.opacity * 200))
gradient = QRadialGradient(ripple.center.x(), ripple.center.y(), ripple.current_radius)
gradient.setColorAt(0, QColor(251, 191, 36, 0))
gradient.setColorAt(0.6, color)
gradient.setColorAt(1, QColor(251, 191, 36, 0))
painter.setBrush(gradient)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(
ripple.center,
int(ripple.current_radius),
int(ripple.current_radius)
)
painter.restore()
else:
# 如果图片加载失败,使用emoji作为备选
painter.setPen(QColor("#fbbf24"))
font = QFont("Segoe UI Emoji", 28)
painter.setFont(font)
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "🧀")
class PrimarySidebar(QWidget):
"""左侧一级菜单"""
category_changed = Signal(str)
settings_clicked = Signal()
logo_clicked = Signal() # 新增:Logo点击信号
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("primary_sidebar")
self.setFixedWidth(80)
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 20, 0, 24)
layout.setSpacing(8)
layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
# Logo - 点击弹出个人信息卡片
self.logo_btn = LogoButton()
self.logo_btn.clicked.connect(self.logo_clicked.emit)
layout.addWidget(self.logo_btn, 0, Qt.AlignmentFlag.AlignHCenter)
layout.addSpacing(28)
# 分类按钮
self.image_btn = CategoryButton("image", "图片工具")
self.image_btn.setChecked(True)
self.image_btn.clicked.connect(lambda: self._on_category_click("image"))
layout.addWidget(self.image_btn)
self.pdf_btn = CategoryButton("file-pdf", "PDF 工具箱")
self.pdf_btn.clicked.connect(lambda: self._on_category_click("pdf"))
layout.addWidget(self.pdf_btn)
self.excel_btn = CategoryButton("file-xls", "Excel 表格")
self.excel_btn.clicked.connect(lambda: self._on_category_click("excel"))
layout.addWidget(self.excel_btn)
self.buttons = {
"image": self.image_btn,
"pdf": self.pdf_btn,
"excel": self.excel_btn
}
layout.addStretch()
# 设置按钮
self.settings_btn = CategoryButton("gear", "设置 / 日志查看")
self.settings_btn.clicked.connect(self.settings_clicked.emit)
layout.addWidget(self.settings_btn)
def _on_category_click(self, category: str):
for cat, btn in self.buttons.items():
btn.setChecked(cat == category)
self.category_changed.emit(category)
def set_category(self, category: str):
for cat, btn in self.buttons.items():
btn.setChecked(cat == category)

425
ui/tool_list.py Normal file
View File

@@ -0,0 +1,425 @@
"""
中间二级菜单组件
- 分类标题
- 搜索框(带图标)
- 工具列表(图标+名称+描述)
- 用户信息
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QButtonGroup, QScrollArea, QFrame, QGraphicsDropShadowEffect
)
from PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QFont, QColor, QPainter, QLinearGradient
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtCore import QByteArray
# 工具图标 SVG
TOOL_ICONS = {
"ph-arrows-in-line-horizontal": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M136 40v176a8 8 0 0 1-16 0V40a8 8 0 0 1 16 0M96 120H35.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32l32 32a8 8 0 0 0 11.32-11.32L35.31 136H96a8 8 0 0 0 0-16m149.66 2.34l-32-32a8 8 0 0 0-11.32 11.32L220.69 120H160a8 8 0 0 0 0 16h60.69l-18.35 18.34a8 8 0 0 0 11.32 11.32l32-32a8 8 0 0 0 0-11.32"/></svg>""",
"ph-arrows-left-right": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="m213.66 181.66l-32 32a8 8 0 0 1-11.32-11.32L188.69 184H48a8 8 0 0 1 0-16h140.69l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32m-139.32-96a8 8 0 0 0 11.32-11.32L67.31 56H208a8 8 0 0 0 0-16H67.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32Z"/></svg>""",
"ph-stamp": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M224 224a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8m0-80v40a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16v-40a16 16 0 0 1 16-16h60.43l-15.52-46.55A40 40 0 0 1 128 32a40 40 0 0 1 35.09 49.45L147.57 128H208a16 16 0 0 1 16 16m-16 0H48v40h160Z"/></svg>""",
"ph-scissors": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M157.73 113.13A8 8 0 0 1 159.82 102L227.48 55.7a8 8 0 0 1 9 13.21l-67.67 46.3a7.92 7.92 0 0 1-4.51 1.4a8 8 0 0 1-6.57-3.48m80.87 85.09a8 8 0 0 1-11.12 2.08L136 137.7l-31.11 21.29a36 36 0 1 1-8.87-12.94l27.46-18.79l-27.46-18.79a36 36 0 1 1 8.87-12.94l123.6 84.59a8 8 0 0 1 2.11 11.1M80 180a20 20 0 1 0-20 20a20 20 0 0 0 20-20m0-104a20 20 0 1 0-20 20a20 20 0 0 0 20-20"/></svg>""",
"ph-files": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M213.66 66.34l-40-40A8 8 0 0 0 168 24H88a16 16 0 0 0-16 16v16H56a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h112a16 16 0 0 0 16-16v-16h16a16 16 0 0 0 16-16V72a8 8 0 0 0-2.34-5.66M168 216H56V72h76.69L168 107.31Zm32-32h-16v-80a8 8 0 0 0-2.34-5.66l-40-40A8 8 0 0 0 136 56H88V40h76.69L200 75.31Zm-56-32a8 8 0 0 1-8 8H88a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8m0 32a8 8 0 0 1-8 8H88a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8"/></svg>""",
"ph-microsoft-word-logo": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M200 24H72a16 16 0 0 0-16 16v24H40a16 16 0 0 0-16 16v96a16 16 0 0 0 16 16h16v24a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V40a16 16 0 0 0-16-16m-40 176H72v-24h88a16 16 0 0 0 16-16v-23h24v63Zm0-79h-32V80h32Zm0-57h-32V40h32ZM40 80h88v96H40Zm160 136h-24v-24a16 16 0 0 0-16-16h-32v-55h72Zm0-111h-32V80a16 16 0 0 0-16-16h-12V40h60Zm-104.5 47.8l-9.36 34.14a6 6 0 0 1-11.56.12L68 162.18l-6.58 24.88a6 6 0 1 1-11.58-3.12l12-45a6 6 0 0 1 11.48-.16l7.18 27.14l9.86-36a6 6 0 0 1 11.56 0l9.86 36l7.18-27.14a6 6 0 0 1 11.48.16l12 45a6 6 0 1 1-11.58 3.12L124 162.18l-6.54 24.88a6 6 0 0 1-11.56-.12l-9.36-34.14Z"/></svg>""",
"ph-eye": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.47 133.47 0 0 1 25 128a133.33 133.33 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.46 133.46 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></svg>""",
"ph-chart-bar": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M224 200h-8V40a8 8 0 0 0-8-8h-56a8 8 0 0 0-8 8v40H96a8 8 0 0 0-8 8v40H48a8 8 0 0 0-8 8v64h-8a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16M160 48h40v152h-40Zm-56 48h40v104h-40Zm-48 48h32v56H56Z"/></svg>""",
"ph-magnifying-glass": """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="m229.66 218.34l-50.07-50.06a88.11 88.11 0 1 0-11.31 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></svg>""",
}
# 工具数据定义
TOOLS_DATA = {
"image": {
"title": "图片工具",
"items": [
{"id": "img-compress", "name": "图片压缩", "icon": "ph-arrows-in-line-horizontal", "desc": "智能无损压缩"},
{"id": "img-convert", "name": "格式转换", "icon": "ph-arrows-left-right", "desc": "JPG/PNG/WEBP"},
{"id": "img-watermark", "name": "图片加水印", "icon": "ph-stamp", "desc": "批量添加水印"},
]
},
"pdf": {
"title": "PDF 工具箱",
"items": [
{"id": "pdf-split", "name": "PDF 拆分", "icon": "ph-scissors", "desc": "提取指定页面"},
{"id": "pdf-merge", "name": "PDF 合并", "icon": "ph-files", "desc": "多文件合并"},
{"id": "pdf-word", "name": "PDF 转 Word", "icon": "ph-microsoft-word-logo", "desc": "保持排版转换"},
]
},
"excel": {
"title": "Excel 表格",
"items": [
{"id": "xls-view", "name": "Excel 预览", "icon": "ph-eye", "desc": "在线查看表格"},
{"id": "xls-chart", "name": "图表生成", "icon": "ph-chart-bar", "desc": "数据可视化"},
]
}
}
def render_svg_icon(svg_data: str, size: int = 18, color: str = "#94a3b8") -> 'QPixmap':
"""渲染SVG图标"""
from PySide6.QtGui import QPixmap
svg_data = svg_data.replace('currentColor', color)
renderer = QSvgRenderer(QByteArray(svg_data.encode()))
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
return pixmap
class ToolButton(QFrame):
"""工具按钮 - 还原HTML设计样式"""
clicked = Signal()
def __init__(self, tool_data: dict, parent=None):
super().__init__(parent)
self.tool_data = tool_data
self._checked = False
self._hovered = False
self.setFixedHeight(56)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setup_ui()
def setup_ui(self):
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(12)
# 图标容器
self.icon_frame = QFrame()
self.icon_frame.setFixedSize(32, 32)
self.icon_frame.setStyleSheet("""
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
""")
icon_layout = QVBoxLayout(self.icon_frame)
icon_layout.setContentsMargins(0, 0, 0, 0)
icon_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
# 图标
self.icon_label = QLabel()
self.icon_label.setFixedSize(18, 18)
self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
svg_data = TOOL_ICONS.get(self.tool_data.get('icon', ''), '')
if svg_data:
self.icon_label.setPixmap(render_svg_icon(svg_data, 18, "#94a3b8"))
icon_layout.addWidget(self.icon_label)
layout.addWidget(self.icon_frame)
# 文字区域
text_layout = QVBoxLayout()
text_layout.setSpacing(2)
self.name_label = QLabel(self.tool_data.get('name', ''))
self.name_label.setStyleSheet("color: #e2e8f0; font-size: 13px; font-weight: 500;")
text_layout.addWidget(self.name_label)
self.desc_label = QLabel(self.tool_data.get('desc', ''))
self.desc_label.setStyleSheet("color: #64748b; font-size: 10px;")
text_layout.addWidget(self.desc_label)
layout.addLayout(text_layout, 1)
self.update_style()
def setChecked(self, checked: bool):
self._checked = checked
self.update_style()
def isChecked(self) -> bool:
return self._checked
def update_style(self):
if self._checked:
self.setStyleSheet("""
ToolButton {
background: #334155;
border-radius: 8px;
border-left: 2px solid #fbbf24;
}
""")
self.icon_frame.setStyleSheet("""
background: #0f172a;
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 6px;
""")
svg_data = TOOL_ICONS.get(self.tool_data.get('icon', ''), '')
if svg_data:
self.icon_label.setPixmap(render_svg_icon(svg_data, 18, "#fbbf24"))
self.name_label.setStyleSheet("color: white; font-size: 13px; font-weight: 500;")
elif self._hovered:
self.setStyleSheet("""
ToolButton {
background: rgba(51, 65, 85, 0.5);
border-radius: 8px;
}
""")
self.icon_frame.setStyleSheet("""
background: #0f172a;
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 6px;
""")
svg_data = TOOL_ICONS.get(self.tool_data.get('icon', ''), '')
if svg_data:
self.icon_label.setPixmap(render_svg_icon(svg_data, 18, "#fbbf24"))
self.name_label.setStyleSheet("color: white; font-size: 13px; font-weight: 500;")
else:
self.setStyleSheet("""
ToolButton {
background: transparent;
border-radius: 8px;
}
""")
self.icon_frame.setStyleSheet("""
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
""")
svg_data = TOOL_ICONS.get(self.tool_data.get('icon', ''), '')
if svg_data:
self.icon_label.setPixmap(render_svg_icon(svg_data, 18, "#94a3b8"))
self.name_label.setStyleSheet("color: #e2e8f0; font-size: 13px; font-weight: 500;")
def enterEvent(self, event):
self._hovered = True
self.update_style()
def leaveEvent(self, event):
self._hovered = False
self.update_style()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
class SearchInput(QWidget):
"""搜索框 - 带前置图标"""
textChanged = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setup_ui()
def setup_ui(self):
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# 容器
container = QFrame()
container.setStyleSheet("""
QFrame {
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
}
QFrame:focus-within {
border-color: rgba(251, 191, 36, 0.5);
}
""")
container_layout = QHBoxLayout(container)
container_layout.setContentsMargins(12, 8, 12, 8)
container_layout.setSpacing(8)
# 搜索图标
icon_label = QLabel()
icon_label.setFixedSize(16, 16)
svg_data = TOOL_ICONS.get("ph-magnifying-glass", "")
if svg_data:
icon_label.setPixmap(render_svg_icon(svg_data, 16, "#64748b"))
container_layout.addWidget(icon_label)
# 输入框
self.input = QLineEdit()
self.input.setPlaceholderText("搜索功能...")
self.input.setStyleSheet("""
QLineEdit {
background: transparent;
border: none;
color: #e2e8f0;
font-size: 13px;
}
QLineEdit::placeholder {
color: #475569;
}
""")
self.input.textChanged.connect(self.textChanged.emit)
container_layout.addWidget(self.input, 1)
layout.addWidget(container)
def text(self) -> str:
return self.input.text()
class UserInfoWidget(QFrame):
"""用户信息区 - 渐变头像"""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(70)
self.setStyleSheet("""
UserInfoWidget {
background: rgba(30, 41, 59, 0.3);
border-top: 1px solid rgba(51, 65, 85, 0.5);
}
""")
self.setup_ui()
def setup_ui(self):
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(12)
# 头像 - 渐变背景
avatar = QFrame()
avatar.setFixedSize(36, 36)
avatar.setStyleSheet("""
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #8b5cf6, stop:1 #6366f1);
border-radius: 18px;
""")
avatar_layout = QVBoxLayout(avatar)
avatar_layout.setContentsMargins(0, 0, 0, 0)
avatar_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar_text = QLabel("SV")
avatar_text.setStyleSheet("color: white; font-size: 12px; font-weight: bold; background: transparent;")
avatar_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar_layout.addWidget(avatar_text)
layout.addWidget(avatar)
# 信息
info_layout = QVBoxLayout()
info_layout.setSpacing(2)
name_label = QLabel("超级会员")
name_label.setStyleSheet("color: white; font-size: 12px; font-weight: 500;")
info_layout.addWidget(name_label)
expire_label = QLabel("有效期至 2026-10")
expire_label.setStyleSheet("color: #64748b; font-size: 10px;")
info_layout.addWidget(expire_label)
layout.addLayout(info_layout, 1)
class SecondarySidebar(QWidget):
"""中间二级菜单"""
tool_selected = Signal(dict)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("secondary_sidebar")
self.setFixedWidth(256)
self.current_category = "image"
self.tool_buttons = []
self._stretch_item = None # 保存stretch引用
self.setup_ui()
self.load_tools("image")
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# 标题区域
title_widget = QWidget()
title_widget.setFixedHeight(80)
title_widget.setStyleSheet("border-bottom: 1px solid rgba(51, 65, 85, 0.5);")
title_layout = QVBoxLayout(title_widget)
title_layout.setContentsMargins(24, 0, 24, 0)
title_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
self.category_title = QLabel("图片工具")
self.category_title.setStyleSheet("color: white; font-size: 18px; font-weight: 600; letter-spacing: 1px;")
title_layout.addWidget(self.category_title)
layout.addWidget(title_widget)
# 搜索区域
search_widget = QWidget()
search_layout = QVBoxLayout(search_widget)
search_layout.setContentsMargins(16, 16, 16, 8)
self.search_input = SearchInput()
self.search_input.textChanged.connect(self.filter_tools)
search_layout.addWidget(self.search_input)
layout.addWidget(search_widget)
# 工具列表区域
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setStyleSheet("background: transparent;")
self.tools_container = QWidget()
self.tools_layout = QVBoxLayout(self.tools_container)
self.tools_layout.setContentsMargins(12, 4, 12, 16)
self.tools_layout.setSpacing(4)
self.tools_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
scroll_area.setWidget(self.tools_container)
layout.addWidget(scroll_area, 1)
# 用户信息
layout.addWidget(UserInfoWidget())
def load_tools(self, category: str):
self.current_category = category
data = TOOLS_DATA.get(category, {})
self.category_title.setText(data.get("title", ""))
# 清空所有内容(包括stretch)
for btn in self.tool_buttons:
self.tools_layout.removeWidget(btn)
btn.deleteLater()
self.tool_buttons.clear()
# 移除旧的stretch
while self.tools_layout.count() > 0:
item = self.tools_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
# 添加工具按钮
for tool in data.get("items", []):
btn = ToolButton(tool)
btn.clicked.connect(lambda t=tool: self.on_tool_clicked(t))
self.tools_layout.addWidget(btn)
self.tool_buttons.append(btn)
# 添加新的stretch
self.tools_layout.addStretch()
def on_tool_clicked(self, tool_data: dict):
# 更新选中状态
for btn in self.tool_buttons:
btn.setChecked(btn.tool_data.get('id') == tool_data.get('id'))
self.tool_selected.emit(tool_data)
def filter_tools(self, text: str):
text = text.lower()
for btn in self.tool_buttons:
name = btn.tool_data.get("name", "").lower()
desc = btn.tool_data.get("desc", "").lower()
btn.setVisible(text in name or text in desc or not text)
def select_tool(self, tool_id: str):
for btn in self.tool_buttons:
if btn.tool_data.get("id") == tool_id:
btn.setChecked(True)
self.tool_selected.emit(btn.tool_data)
break

495
ui/workspace.py Normal file
View File

@@ -0,0 +1,495 @@
"""
右侧工作区基类
- 装饰性背景光晕
- 毛玻璃效果头部
- 面包屑导航 + 版本标签
- 上传区域(虚线边框+图标)
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFrame, QScrollArea, QFileDialog, QGraphicsDropShadowEffect,
QGraphicsBlurEffect
)
from PySide6.QtCore import Signal, Qt, QSize
from PySide6.QtGui import (
QFont, QDragEnterEvent, QDropEvent, QPainter,
QColor, QRadialGradient, QLinearGradient, QPen, QBrush
)
class BackgroundDecoration(QWidget):
"""装饰性背景 - 渐变光晕"""
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# 右上角奶酪色光晕
gradient1 = QRadialGradient(
self.width() + 50, -50, # 圆心
500 # 半径
)
gradient1.setColorAt(0, QColor(251, 191, 36, 25)) # cheese-500/10
gradient1.setColorAt(1, QColor(251, 191, 36, 0))
painter.setBrush(gradient1)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(
int(self.width() * 0.5), int(-self.height() * 0.2),
int(self.width() * 0.7), int(self.height() * 0.7)
)
# 左侧紫色光晕
gradient2 = QRadialGradient(
self.width() * 0.1, self.height() * 0.3,
350
)
gradient2.setColorAt(0, QColor(139, 92, 246, 12)) # purple-500/5
gradient2.setColorAt(1, QColor(139, 92, 246, 0))
painter.setBrush(gradient2)
painter.drawEllipse(
int(-self.width() * 0.1), int(self.height() * 0.1),
int(self.width() * 0.5), int(self.height() * 0.5)
)
class UploadArea(QFrame):
"""上传区域 - 还原HTML设计样式"""
files_dropped = Signal(list)
def __init__(self, accept_types: str = "所有文件", parent=None):
super().__init__(parent)
self.accept_types = accept_types
self.setAcceptDrops(True)
self.setMinimumHeight(208) # h-52 = 13rem = 208px
self.setCursor(Qt.CursorShape.PointingHandCursor)
self._hovered = False
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(12)
# 图标容器 (圆形背景)
self.icon_container = QFrame()
self.icon_container.setFixedSize(56, 56)
self.icon_container.setStyleSheet("""
background: #334155;
border-radius: 28px;
""")
icon_layout = QVBoxLayout(self.icon_container)
icon_layout.setContentsMargins(0, 0, 0, 0)
icon_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
# 云上传图标
icon_label = QLabel("☁️")
icon_label.setFont(QFont("Segoe UI Emoji", 24))
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon_label.setStyleSheet("background: transparent;")
icon_layout.addWidget(icon_label)
layout.addWidget(self.icon_container, 0, Qt.AlignmentFlag.AlignCenter)
# 主文字
self.title_label = QLabel("点击或拖拽文件到此处")
self.title_label.setStyleSheet("color: white; font-size: 16px; font-weight: 500; background: transparent;")
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.title_label)
# 副文字
self.desc_label = QLabel(f"支持批量上传 ({self.accept_types})")
self.desc_label.setStyleSheet("color: #64748b; font-size: 13px; background: transparent;")
self.desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.desc_label)
self.update_style()
def update_style(self):
if self._hovered:
self.setStyleSheet("""
UploadArea {
background: rgba(30, 41, 59, 0.5);
border: 2px dashed rgba(251, 191, 36, 0.5);
border-radius: 16px;
}
""")
else:
self.setStyleSheet("""
UploadArea {
background: rgba(30, 41, 59, 0.3);
border: 2px dashed #334155;
border-radius: 16px;
}
""")
def enterEvent(self, event):
self._hovered = True
self.update_style()
def leaveEvent(self, event):
self._hovered = False
self.update_style()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.open_file_dialog()
def open_file_dialog(self):
files, _ = QFileDialog.getOpenFileNames(
self, "选择文件", "", f"{self.accept_types}"
)
if files:
self.files_dropped.emit(files)
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
self._hovered = True
self.update_style()
def dragLeaveEvent(self, event):
self._hovered = False
self.update_style()
def dropEvent(self, event: QDropEvent):
self._hovered = False
self.update_style()
files = []
for url in event.mimeData().urls():
if url.isLocalFile():
files.append(url.toLocalFile())
if files:
self.files_dropped.emit(files)
class GlassHeader(QFrame):
"""毛玻璃效果头部"""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(80)
self.setStyleSheet("""
GlassHeader {
background: rgba(30, 41, 59, 0.7);
border-bottom: 1px solid rgba(51, 65, 85, 0.3);
}
""")
class BaseWorkspace(QWidget):
"""工作区基类"""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("workspace")
self.setup_base_ui()
def setup_base_ui(self):
# 主布局
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# 背景装饰层
self.bg_decoration = BackgroundDecoration(self)
# 头部区域
self.header = GlassHeader()
header_layout = QVBoxLayout(self.header)
header_layout.setContentsMargins(32, 16, 32, 16)
header_layout.setSpacing(4)
# 面包屑
breadcrumb_layout = QHBoxLayout()
breadcrumb_layout.setSpacing(8)
self.breadcrumb_category = QLabel("首页")
self.breadcrumb_category.setStyleSheet("color: #64748b; font-size: 12px;")
breadcrumb_layout.addWidget(self.breadcrumb_category)
arrow = QLabel("›")
arrow.setStyleSheet("color: #64748b; font-size: 12px;")
breadcrumb_layout.addWidget(arrow)
self.breadcrumb_tool = QLabel("控制台")
self.breadcrumb_tool.setStyleSheet("color: #fbbf24; font-size: 12px;")
breadcrumb_layout.addWidget(self.breadcrumb_tool)
breadcrumb_layout.addStretch()
header_layout.addLayout(breadcrumb_layout)
# 标题行
title_layout = QHBoxLayout()
# 标题 + 版本标签
title_container = QHBoxLayout()
title_container.setSpacing(8)
self.title_label = QLabel("欢迎回来")
self.title_label.setStyleSheet("color: white; font-size: 22px; font-weight: 700;")
title_container.addWidget(self.title_label)
self.version_label = QLabel("v2.1")
self.version_label.setStyleSheet("""
color: #64748b;
font-size: 12px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 4px;
padding: 2px 8px;
""")
self.version_label.setVisible(False)
title_container.addWidget(self.version_label)
title_container.addStretch()
title_layout.addLayout(title_container, 1)
# 按钮区域
btn_layout = QHBoxLayout()
btn_layout.setSpacing(12)
self.history_btn = QPushButton("🕐 历史")
self.history_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: 1px solid #334155;
border-radius: 8px;
color: #cbd5e1;
padding: 8px 16px;
font-size: 13px;
}
QPushButton:hover {
background: #334155;
}
""")
btn_layout.addWidget(self.history_btn)
self.export_btn = QPushButton("📤 导出")
self.export_btn.setStyleSheet("""
QPushButton {
background: #f59e0b;
border: none;
border-radius: 8px;
color: white;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background: #fbbf24;
}
""")
btn_layout.addWidget(self.export_btn)
title_layout.addLayout(btn_layout)
header_layout.addLayout(title_layout)
self.main_layout.addWidget(self.header)
# 内容区域
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setStyleSheet("background: transparent;")
self.content_widget = QWidget()
self.content_widget.setStyleSheet("background: transparent;")
self.content_layout = QVBoxLayout(self.content_widget)
self.content_layout.setContentsMargins(32, 24, 32, 24)
self.content_layout.setSpacing(24)
scroll_area.setWidget(self.content_widget)
self.main_layout.addWidget(scroll_area, 1)
def resizeEvent(self, event):
super().resizeEvent(event)
# 调整背景装饰大小
self.bg_decoration.setGeometry(0, 0, self.width(), self.height())
self.bg_decoration.lower()
def set_breadcrumb(self, category: str, tool: str):
self.breadcrumb_category.setText(category)
self.breadcrumb_tool.setText(tool)
def set_title(self, title: str):
self.title_label.setText(title)
self.version_label.setVisible(bool(title and title != "欢迎回来"))
def clear_content(self):
while self.content_layout.count():
item = self.content_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
class ShortcutCard(QFrame):
"""快捷入口卡片"""
clicked = Signal()
def __init__(self, data: dict, parent=None):
super().__init__(parent)
self.data = data
self.setFixedSize(200, 140)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self._hovered = False
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(8)
# 图标
color = self.data.get("color", "#64748b")
self.icon_frame = QFrame()
self.icon_frame.setFixedSize(40, 40)
r, g, b = self.hex_to_rgb(color)
self.icon_frame.setStyleSheet(f"""
background: rgba({r}, {g}, {b}, 0.1);
border-radius: 8px;
""")
icon_layout = QVBoxLayout(self.icon_frame)
icon_layout.setContentsMargins(0, 0, 0, 0)
icon_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon = QLabel(self.data.get("icon", ""))
icon.setFont(QFont("Segoe UI Emoji", 16))
icon.setStyleSheet(f"color: {color}; background: transparent;")
icon_layout.addWidget(icon)
layout.addWidget(self.icon_frame)
# 名称
name = QLabel(self.data.get("name", ""))
name.setStyleSheet("color: white; font-size: 14px; font-weight: 500;")
layout.addWidget(name)
# 描述
desc = QLabel(self.data.get("desc", ""))
desc.setStyleSheet("color: #64748b; font-size: 11px;")
layout.addWidget(desc)
layout.addStretch()
self.update_style()
def update_style(self):
if self._hovered:
self.setStyleSheet("""
ShortcutCard {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(251, 191, 36, 0.5);
border-radius: 16px;
}
""")
else:
self.setStyleSheet("""
ShortcutCard {
background: rgba(30, 41, 59, 0.5);
border: 1px solid #334155;
border-radius: 16px;
}
""")
def enterEvent(self, event):
self._hovered = True
self.update_style()
def leaveEvent(self, event):
self._hovered = False
self.update_style()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
@staticmethod
def hex_to_rgb(hex_color: str) -> tuple:
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
class WelcomeWorkspace(BaseWorkspace):
"""欢迎页面工作区"""
tool_clicked = Signal(str, str)
def __init__(self, parent=None):
super().__init__(parent)
self.setup_welcome_ui()
def setup_welcome_ui(self):
self.set_breadcrumb("首页", "控制台")
self.set_title("欢迎回来")
self.version_label.setVisible(False)
self.history_btn.hide()
self.export_btn.hide()
# 中心内容
center_widget = QWidget()
center_layout = QVBoxLayout(center_widget)
center_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
center_layout.setSpacing(24)
# Logo区域
logo_container = QFrame()
logo_container.setFixedSize(128, 128)
logo_container.setStyleSheet("""
background: #1e293b;
border: 1px solid #334155;
border-radius: 64px;
""")
logo_layout = QVBoxLayout(logo_container)
logo_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
logo = QLabel("🧀")
logo.setFont(QFont("Segoe UI Emoji", 48))
logo.setStyleSheet("color: #fbbf24; background: transparent;")
logo_layout.addWidget(logo)
center_layout.addWidget(logo_container, 0, Qt.AlignmentFlag.AlignCenter)
# 标题
title = QLabel("开始你的创作")
title.setFont(QFont("Microsoft YaHei", 28, QFont.Weight.Bold))
title.setStyleSheet("color: white;")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
center_layout.addWidget(title)
# 描述
desc = QLabel("从左侧选择一个工具。无论是压缩图片、拆分PDF还是处理数据,我们都能搞定。")
desc.setStyleSheet("color: #94a3b8; font-size: 14px;")
desc.setAlignment(Qt.AlignmentFlag.AlignCenter)
desc.setWordWrap(True)
desc.setMaximumWidth(500)
center_layout.addWidget(desc)
center_layout.addSpacing(20)
# 快捷入口
shortcuts_layout = QHBoxLayout()
shortcuts_layout.setSpacing(16)
shortcuts_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
shortcuts = [
{"id": "img-compress", "category": "image", "name": "图片压缩", "desc": "智能无损压缩", "icon": "🖼", "color": "#3b82f6"},
{"id": "pdf-split", "category": "pdf", "name": "PDF 拆分", "desc": "提取特定页面", "icon": "✂️", "color": "#ef4444"},
{"id": "img-convert", "category": "image", "name": "格式转换", "desc": "格式互转工具", "icon": "🔄", "color": "#22c55e"},
]
for item in shortcuts:
card = ShortcutCard(item)
card.clicked.connect(lambda i=item: self.tool_clicked.emit(i["id"], i["category"]))
shortcuts_layout.addWidget(card)
center_layout.addLayout(shortcuts_layout)
self.content_layout.addStretch()
self.content_layout.addWidget(center_widget)
self.content_layout.addStretch()