refactor: 重构为极简截图上传工具

- 简化项目定位:从智能工具转为极简截图上传工具
- 移除重型依赖:torch、transformers、paddleocr、SQLAlchemy
- 新增轻量级核心模块:
  - config.py: 简化 YAML 配置管理
  - database.py: 原生 SQLite 存储
  - screenshot.py: 截图功能(全屏/区域)
  - uploader.py: 云端上传(支持 custom/telegraph/imgur)
  - plugins/ocr.py: 可选 RapidOCR 插件
- 重写主窗口:专注核心功能,移除复杂 UI
- 更新依赖:核心 ~50MB,OCR 可选
- 更新文档:新的 README 和需求分析 v2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-02-12 15:50:51 +08:00
parent a5e50876a0
commit e853161975
37 changed files with 2109 additions and 9266 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +0,0 @@
"""
GUI样式和主题模块
提供颜色方案和主题样式表
"""
from src.gui.styles.colors import ColorScheme, COLORS, get_color
from src.gui.styles.theme import ThemeStyles
# 浏览视图样式(如果存在)
try:
from src.gui.styles.browse_style import (
get_style, get_category_color, get_category_name,
CATEGORY_COLORS, CATEGORY_NAMES
)
_has_browse_style = True
except ImportError:
_has_browse_style = False
__all__ = [
# 颜色和主题
'ColorScheme',
'COLORS',
'get_color',
'ThemeStyles',
]
# 如果浏览样式存在,添加到导出
if _has_browse_style:
__all__.extend([
'get_style',
'get_category_color',
'get_category_name',
'CATEGORY_COLORS',
'CATEGORY_NAMES',
])

View File

@@ -1,341 +0,0 @@
"""
浏览视图样式定义
包含卡片、按钮、对话框等组件的样式
"""
# 通用样式
COMMON_STYLES = """
QWidget {
font-family: "Microsoft YaHei", "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
}
"""
# 卡片样式
CARD_STYLES = """
RecordCard {
background-color: white;
border-radius: 12px;
border: 1px solid #E8E8E8;
}
RecordCard:hover {
background-color: #FAFAFA;
border: 1px solid #4A90E2;
}
"""
# 按钮样式
BUTTON_STYLES = {
'primary': """
QPushButton {
background-color: #4A90E2;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
}
QPushButton:hover {
background-color: #357ABD;
}
QPushButton:pressed {
background-color: #2E6FA8;
}
QPushButton:disabled {
background-color: #BDC3C7;
color: #ECF0F1;
}
""",
'success': """
QPushButton {
background-color: #58D68D;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
}
QPushButton:hover {
background-color: #48C9B0;
}
QPushButton:pressed {
background-color: #45B39D;
}
""",
'danger': """
QPushButton {
background-color: #EC7063;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
}
QPushButton:hover {
background-color: #E74C3C;
}
QPushButton:pressed {
background-color: #C0392B;
}
""",
'secondary': """
QPushButton {
background-color: #95A5A6;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
}
QPushButton:hover {
background-color: #7F8C8D;
}
QPushButton:pressed {
background-color: #6C7A7D;
}
""",
}
# 分类按钮颜色
CATEGORY_COLORS = {
"TODO": "#5DADE2",
"NOTE": "#58D68D",
"IDEA": "#F5B041",
"REF": "#AF7AC5",
"FUNNY": "#EC7063",
"TEXT": "#95A5A6",
}
# 分类名称
CATEGORY_NAMES = {
"TODO": "待办",
"NOTE": "笔记",
"IDEA": "灵感",
"REF": "参考",
"FUNNY": "趣味",
"TEXT": "文本",
}
# 输入框样式
INPUT_STYLES = """
QLineEdit {
padding: 10px 15px;
border: 2px solid #E0E0E0;
border-radius: 8px;
font-size: 14px;
background-color: #FAFAFA;
color: #333;
}
QLineEdit:focus {
border: 2px solid #4A90E2;
background-color: white;
}
QLineEdit:disabled {
background-color: #ECF0F1;
color: #95A5A6;
}
"""
# 文本编辑框样式
TEXTEDIT_STYLES = """
QTextEdit {
border: 1px solid #E0E0E0;
border-radius: 8px;
padding: 10px;
background-color: #FAFAFA;
font-size: 13px;
line-height: 1.6;
color: #333;
}
QTextEdit:focus {
border: 1px solid #4A90E2;
background-color: white;
}
"""
# 下拉框样式
COMBOBOX_STYLES = """
QComboBox {
padding: 8px 15px;
border: 2px solid #E0E0E0;
border-radius: 8px;
font-size: 14px;
background-color: white;
color: #333;
}
QComboBox:hover {
border: 2px solid #4A90E2;
}
QComboBox::drop-down {
border: none;
width: 30px;
}
QComboBox::down-arrow {
image: none;
border: 5px solid transparent;
border-top-color: #333;
margin-right: 10px;
}
QComboBox QAbstractItemView {
border: 1px solid #E0E0E0;
border-radius: 8px;
background-color: white;
selection-background-color: #4A90E2;
selection-color: white;
padding: 5px;
}
"""
# 滚动区域样式
SCROLLAREA_STYLES = """
QScrollArea {
border: none;
background-color: transparent;
}
QScrollBar:vertical {
border: none;
background-color: #F5F5F5;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #BDC3C7;
border-radius: 6px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background-color: #95A5A6;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
border: none;
background: none;
}
QScrollBar:horizontal {
border: none;
background-color: #F5F5F5;
height: 12px;
border-radius: 6px;
}
QScrollBar::handle:horizontal {
background-color: #BDC3C7;
border-radius: 6px;
min-width: 30px;
}
QScrollBar::handle:horizontal:hover {
background-color: #95A5A6;
}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
border: none;
background: none;
}
"""
# 框架样式
FRAME_STYLES = """
QFrame {
background-color: white;
border-radius: 12px;
border: 1px solid #E0E0E0;
}
"""
# 标签样式
LABEL_STYLES = {
'title': """
QLabel {
font-size: 24px;
font-weight: bold;
color: #2C3E50;
}
""",
'subtitle': """
QLabel {
font-size: 18px;
font-weight: bold;
color: #34495E;
}
""",
'body': """
QLabel {
font-size: 14px;
color: #333;
}
""",
'caption': """
QLabel {
font-size: 12px;
color: #999;
}
""",
}
# 对话框样式
DIALOG_STYLES = """
QDialog {
background-color: #F5F7FA;
}
"""
# 工具栏样式
TOOLBAR_STYLES = """
QFrame {
background-color: white;
border-radius: 12px;
padding: 15px;
}
"""
def get_style(style_type: str, *args) -> str:
"""
获取样式字符串
Args:
style_type: 样式类型 (button, input, label等)
*args: 额外参数 (如button类型: primary, secondary等)
Returns:
样式字符串
"""
styles = {
'button': BUTTON_STYLES.get(args[0] if args else 'primary', ''),
'input': INPUT_STYLES,
'textedit': TEXTEDIT_STYLES,
'combobox': COMBOBOX_STYLES,
'scrollarea': SCROLLAREA_STYLES,
'frame': FRAME_STYLES,
'label': LABEL_STYLES.get(args[0] if args else 'body', ''),
'dialog': DIALOG_STYLES,
'toolbar': TOOLBAR_STYLES,
}
return styles.get(style_type, '')
def get_category_color(category: str) -> str:
"""
获取分类颜色
Args:
category: 分类代码
Returns:
颜色代码
"""
return CATEGORY_COLORS.get(category, "#95A5A6")
def get_category_name(category: str) -> str:
"""
获取分类中文名
Args:
category: 分类代码
Returns:
中文名
"""
return CATEGORY_NAMES.get(category, category)

View File

@@ -1,122 +0,0 @@
"""
颜色定义模块
定义应用程序使用的颜色方案,采用米白色系
"""
from dataclasses import dataclass
from typing import Dict
@dataclass
class ColorScheme:
"""颜色方案类 - 米白色主题"""
# 主色调 - 米白色系
background_primary: str = "#FAF8F5" # 主背景色 - 米白色
background_secondary: str = "#F0ECE8" # 次要背景色 - 浅米色
background_card: str = "#FFFFFF" # 卡片背景色 - 纯白
# 文字颜色
text_primary: str = "#2C2C2C" # 主要文字 - 深灰
text_secondary: str = "#666666" # 次要文字 - 中灰
text_disabled: str = "#999999" # 禁用文字 - 浅灰
text_hint: str = "#B8B8B8" # 提示文字 - 更浅灰
# 强调色 - 温暖的棕色系
accent_primary: str = "#8B6914" # 主要强调色 - 金棕
accent_secondary: str = "#A67C52" # 次要强调色 - 驼色
accent_hover: str = "#D4A574" # 悬停色 - 浅驼
# 边框和分割线
border_light: str = "#E8E4E0" # 浅边框
border_medium: str = "#D0CCC6" # 中边框
border_dark: str = "#B0ABA5" # 深边框
# 功能色
success: str = "#6B9B3A" # 成功 - 橄榄绿
warning: str = "#D9A518" # 警告 - 金黄
error: str = "#C94B38" # 错误 - 铁锈红
info: str = "#5B8FB9" # 信息 - 钢蓝
# 阴影
shadow_light: str = "rgba(0, 0, 0, 0.05)" # 浅阴影
shadow_medium: str = "rgba(0, 0, 0, 0.1)" # 中阴影
shadow_dark: str = "rgba(0, 0, 0, 0.15)" # 深阴影
# 侧边栏
sidebar_background: str = "#F5F1EC" # 侧边栏背景
sidebar_item_hover: str = "#EBE7E2" # 侧边栏项悬停
sidebar_item_active: str = "#E0DCD6" # 侧边栏项激活
sidebar_text: str = "#4A4642" # 侧边栏文字
# 按钮
button_primary_bg: str = "#8B6914" # 主按钮背景
button_primary_hover: str = "#A67C52" # 主按钮悬停
button_primary_text: str = "#FFFFFF" # 主按钮文字
button_secondary_bg: str = "#E8E4E0" # 次要按钮背景
button_secondary_hover: str = "#D0CCC6" # 次要按钮悬停
button_secondary_text: str = "#2C2C2C" # 次要按钮文字
# 输入框
input_background: str = "#FFFFFF" # 输入框背景
input_border: str = "#D0CCC6" # 输入框边框
input_focus_border: str = "#8B6914" # 输入框聚焦边框
input_placeholder: str = "#B8B8B8" # 输入框占位符
def to_dict(self) -> Dict[str, str]:
"""转换为字典"""
return {
'background_primary': self.background_primary,
'background_secondary': self.background_secondary,
'background_card': self.background_card,
'text_primary': self.text_primary,
'text_secondary': self.text_secondary,
'text_disabled': self.text_disabled,
'text_hint': self.text_hint,
'accent_primary': self.accent_primary,
'accent_secondary': self.accent_secondary,
'accent_hover': self.accent_hover,
'border_light': self.border_light,
'border_medium': self.border_medium,
'border_dark': self.border_dark,
'success': self.success,
'warning': self.warning,
'error': self.error,
'info': self.info,
'shadow_light': self.shadow_light,
'shadow_medium': self.shadow_medium,
'shadow_dark': self.shadow_dark,
'sidebar_background': self.sidebar_background,
'sidebar_item_hover': self.sidebar_item_hover,
'sidebar_item_active': self.sidebar_item_active,
'sidebar_text': self.sidebar_text,
'button_primary_bg': self.button_primary_bg,
'button_primary_hover': self.button_primary_hover,
'button_primary_text': self.button_primary_text,
'button_secondary_bg': self.button_secondary_bg,
'button_secondary_hover': self.button_secondary_hover,
'button_secondary_text': self.button_secondary_text,
'input_background': self.input_background,
'input_border': self.input_border,
'input_focus_border': self.input_focus_border,
'input_placeholder': self.input_placeholder,
}
# 全局颜色方案实例
COLORS = ColorScheme()
def get_color(name: str) -> str:
"""
获取颜色值
Args:
name: 颜色名称
Returns:
颜色值(十六进制字符串)
"""
return getattr(COLORS, name, "#000000")

View File

@@ -1,437 +0,0 @@
"""
主题样式表模块
定义 Qt 样式表QSS实现米白色主题
"""
from .colors import COLORS
class ThemeStyles:
"""主题样式表类"""
@staticmethod
def get_main_window_stylesheet() -> str:
"""
获取主窗口样式表
Returns:
QSS 样式表字符串
"""
return f"""
/* ========== 主窗口 ========== */
QMainWindow {{
background-color: {COLORS.background_primary};
}}
/* ========== 侧边栏 ========== */
QWidget#sidebar {{
background-color: {COLORS.sidebar_background};
border-right: 1px solid {COLORS.border_light};
}}
QPushButton#navButton {{
background-color: transparent;
border: none;
border-radius: 8px;
padding: 12px 16px;
text-align: left;
color: {COLORS.sidebar_text};
font-size: 14px;
font-weight: 500;
}}
QPushButton#navButton:hover {{
background-color: {COLORS.sidebar_item_hover};
}}
QPushButton#navButton:checked {{
background-color: {COLORS.sidebar_item_active};
color: {COLORS.accent_primary};
font-weight: 600;
}}
QWidget#navSeparator {{
background-color: {COLORS.border_light};
max-height: 1px;
min-height: 1px;
margin: 8px 16px;
}}
/* ========== 主内容区域 ========== */
QWidget#contentArea {{
background-color: {COLORS.background_primary};
}}
QStackedWidget#contentStack {{
background-color: transparent;
border: none;
}}
/* ========== 标题 ========== */
QLabel#pageTitle {{
color: {COLORS.text_primary};
font-size: 24px;
font-weight: 600;
padding: 8px 0;
}}
QLabel#sectionTitle {{
color: {COLORS.text_primary};
font-size: 18px;
font-weight: 600;
padding: 4px 0;
}}
/* ========== 卡片 ========== */
QWidget#card {{
background-color: {COLORS.background_card};
border-radius: 12px;
border: 1px solid {COLORS.border_light};
padding: 16px;
}}
/* ========== 按钮 ========== */
QPushButton {{
background-color: {COLORS.button_secondary_bg};
color: {COLORS.button_secondary_text};
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
}}
QPushButton:hover {{
background-color: {COLORS.button_secondary_hover};
}}
QPushButton:pressed {{
background-color: {COLORS.border_medium};
}}
QPushButton:disabled {{
background-color: {COLORS.background_secondary};
color: {COLORS.text_disabled};
}}
QPushButton#primaryButton {{
background-color: {COLORS.button_primary_bg};
color: {COLORS.button_primary_text};
}}
QPushButton#primaryButton:hover {{
background-color: {COLORS.button_primary_hover};
}}
/* ========== 输入框 ========== */
QLineEdit, QTextEdit, QPlainTextEdit {{
background-color: {COLORS.input_background};
border: 1px solid {COLORS.input_border};
border-radius: 6px;
padding: 8px 12px;
color: {COLORS.text_primary};
font-size: 14px;
selection-background-color: {COLORS.accent_secondary};
}}
QLineEdit:hover, QTextEdit:hover, QPlainTextEdit:hover {{
border-color: {COLORS.border_dark};
}}
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {{
border: 2px solid {COLORS.input_focus_border};
padding: 7px 11px;
}}
QLineEdit:disabled, QTextEdit:disabled, QPlainTextEdit:disabled {{
background-color: {COLORS.background_secondary};
color: {COLORS.text_disabled};
}}
/* ========== 下拉框 ========== */
QComboBox {{
background-color: {COLORS.input_background};
border: 1px solid {COLORS.input_border};
border-radius: 6px;
padding: 8px 12px;
color: {COLORS.text_primary};
font-size: 14px;
}}
QComboBox:hover {{
border-color: {COLORS.border_dark};
}}
QComboBox:focus {{
border: 2px solid {COLORS.input_focus_border};
}}
QComboBox::drop-down {{
border: none;
width: 20px;
}}
QComboBox::down-arrow {{
image: none;
border: 5px solid transparent;
border-top-color: {COLORS.text_secondary};
margin-right: 5px;
}}
QComboBox QAbstractItemView {{
background-color: {COLORS.background_card};
border: 1px solid {COLORS.border_light};
selection-background-color: {COLORS.sidebar_item_active};
selection-color: {COLORS.text_primary};
padding: 4px;
}}
/* ========== 滚动条 ========== */
QScrollBar:vertical {{
background-color: transparent;
width: 10px;
margin: 0px;
}}
QScrollBar::handle:vertical {{
background-color: {COLORS.border_medium};
border-radius: 5px;
min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{
background-color: {COLORS.border_dark};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0px;
}}
QScrollBar:horizontal {{
background-color: transparent;
height: 10px;
margin: 0px;
}}
QScrollBar::handle:horizontal {{
background-color: {COLORS.border_medium};
border-radius: 5px;
min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{
background-color: {COLORS.border_dark};
}}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
width: 0px;
}}
/* ========== 分组框 ========== */
QGroupBox {{
background-color: {COLORS.background_card};
border: 1px solid {COLORS.border_light};
border-radius: 8px;
margin-top: 12px;
padding: 16px;
font-size: 14px;
font-weight: 600;
color: {COLORS.text_primary};
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 16px;
padding: 0 8px;
}}
/* ========== 标签页 ========== */
QTabWidget::pane {{
background-color: {COLORS.background_card};
border: 1px solid {COLORS.border_light};
border-radius: 8px;
top: -1px;
}}
QTabBar::tab {{
background-color: {COLORS.background_secondary};
color: {COLORS.text_secondary};
border: none;
padding: 10px 20px;
margin-right: 4px;
font-size: 14px;
font-weight: 500;
}}
QTabBar::tab:selected {{
background-color: {COLORS.background_card};
color: {COLORS.text_primary};
font-weight: 600;
}}
QTabBar::tab:hover:!selected {{
background-color: {COLORS.sidebar_item_hover};
}}
/* ========== 复选框 ========== */
QCheckBox {{
color: {COLORS.text_primary};
font-size: 14px;
spacing: 8px;
}}
QCheckBox::indicator {{
width: 18px;
height: 18px;
border: 2px solid {COLORS.input_border};
border-radius: 4px;
background-color: {COLORS.input_background};
}}
QCheckBox::indicator:hover {{
border-color: {COLORS.border_dark};
}}
QCheckBox::indicator:checked {{
background-color: {COLORS.accent_primary};
border-color: {COLORS.accent_primary};
image: none;
}}
QCheckBox::indicator:checked::after {{
content: "";
color: {COLORS.button_primary_text};
}}
/* ========== 单选框 ========== */
QRadioButton {{
color: {COLORS.text_primary};
font-size: 14px;
spacing: 8px;
}}
QRadioButton::indicator {{
width: 18px;
height: 18px;
border: 2px solid {COLORS.input_border};
border-radius: 9px;
background-color: {COLORS.input_background};
}}
QRadioButton::indicator:hover {{
border-color: {COLORS.border_dark};
}}
QRadioButton::indicator:checked {{
background-color: {COLORS.input_background};
border-color: {COLORS.accent_primary};
}}
QRadioButton::indicator:checked::after {{
content: "";
width: 8px;
height: 8px;
border-radius: 4px;
background-color: {COLORS.accent_primary};
}}
/* ========== 进度条 ========== */
QProgressBar {{
background-color: {COLORS.background_secondary};
border: none;
border-radius: 6px;
height: 8px;
text-align: center;
color: {COLORS.text_primary};
font-size: 12px;
}}
QProgressBar::chunk {{
background-color: {COLORS.accent_primary};
border-radius: 6px;
}}
/* ========== 分隔线 ========== */
QFrame[frameShape="4"], QFrame[frameShape="5"] {{
color: {COLORS.border_light};
}}
/* ========== 工具提示 ========== */
QToolTip {{
background-color: {COLORS.text_primary};
color: {COLORS.background_primary};
border: none;
border-radius: 4px;
padding: 6px 10px;
font-size: 12px;
}}
/* ========== 菜单 ========== */
QMenu {{
background-color: {COLORS.background_card};
border: 1px solid {COLORS.border_light};
border-radius: 6px;
padding: 4px;
}}
QMenu::item {{
padding: 8px 16px;
border-radius: 4px;
color: {COLORS.text_primary};
font-size: 14px;
}}
QMenu::item:selected {{
background-color: {COLORS.sidebar_item_active};
color: {COLORS.accent_primary};
}}
QMenu::separator {{
height: 1px;
background-color: {COLORS.border_light};
margin: 4px 8px;
}}
/* ========== 列表 ========== */
QListWidget {{
background-color: {COLORS.background_card};
border: 1px solid {COLORS.border_light};
border-radius: 6px;
padding: 4px;
}}
QListWidget::item {{
padding: 8px 12px;
border-radius: 4px;
color: {COLORS.text_primary};
font-size: 14px;
}}
QListWidget::item:hover {{
background-color: {COLORS.sidebar_item_hover};
}}
QListWidget::item:selected {{
background-color: {COLORS.sidebar_item_active};
color: {COLORS.accent_primary};
}}
/* ========== 状态栏 ========== */
QStatusBar {{
background-color: {COLORS.background_secondary};
color: {COLORS.text_secondary};
border-top: 1px solid {COLORS.border_light};
font-size: 12px;
}}
"""
@staticmethod
def apply_style(widget) -> None:
"""
应用样式到部件
Args:
widget: Qt 部件
"""
widget.setStyleSheet(ThemeStyles.get_main_window_stylesheet())

View File

@@ -1,86 +0,0 @@
"""
自定义GUI组件
"""
# 浏览相关组件
from src.gui.widgets.record_card import RecordCard
from src.gui.widgets.record_detail_dialog import RecordDetailDialog
from src.gui.widgets.browse_view import BrowseView
# 图片处理组件
from src.gui.widgets.screenshot_widget import (
ScreenshotWidget,
ScreenshotOverlay,
QuickScreenshotHelper,
take_screenshot
)
from src.gui.widgets.clipboard_monitor import (
ClipboardMonitor,
ClipboardImagePicker
)
from src.gui.widgets.image_picker import (
ImagePicker,
DropArea,
QuickImagePicker
)
from src.gui.widgets.image_preview_widget import (
ImagePreviewWidget,
ZoomMode,
ImageLabel
)
# 结果展示和消息处理组件
from src.gui.widgets.result_widget import ResultWidget, QuickResultDialog
from src.gui.widgets.message_handler import (
MessageHandler,
ErrorLogViewer,
ProgressDialog,
LogLevel,
show_info,
show_warning,
show_error,
ask_yes_no,
ask_ok_cancel
)
__all__ = [
# 浏览相关
'RecordCard',
'RecordDetailDialog',
'BrowseView',
# 截图相关
'ScreenshotWidget',
'ScreenshotOverlay',
'QuickScreenshotHelper',
'take_screenshot',
# 剪贴板相关
'ClipboardMonitor',
'ClipboardImagePicker',
# 图片选择相关
'ImagePicker',
'DropArea',
'QuickImagePicker',
# 图片预览相关
'ImagePreviewWidget',
'ZoomMode',
'ImageLabel',
# 结果展示相关
'ResultWidget',
'QuickResultDialog',
# 消息处理相关
'MessageHandler',
'ErrorLogViewer',
'ProgressDialog',
'LogLevel',
'show_info',
'show_warning',
'show_error',
'ask_yes_no',
'ask_ok_cancel',
]

View File

@@ -1,478 +0,0 @@
"""
浏览视图组件
实现分类浏览功能,包括:
- 全部记录列表视图
- 按分类筛选
- 卡片样式展示
- 记录详情查看
"""
from typing import List, Optional
from datetime import datetime
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QScrollArea, QLabel, QLineEdit, QFrame, QSizePolicy,
QMessageBox, QInputDialog
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
from PyQt6.QtGui import QFont
from src.models.database import Record, RecordCategory, get_db
from src.gui.widgets.record_card import RecordCard
from src.gui.widgets.record_detail_dialog import RecordDetailDialog
class BrowseView(QWidget):
"""
浏览视图组件
显示所有记录的卡片列表,支持分类筛选
"""
# 定义信号:记录被修改时发出
record_modified = pyqtSignal(int) # 记录ID
record_deleted = pyqtSignal(int) # 记录ID
def __init__(self, parent: Optional[QWidget] = None):
"""
初始化浏览视图
Args:
parent: 父组件
"""
super().__init__(parent)
self.current_category = "ALL" # 当前筛选分类ALL表示全部
self.search_text = "" # 搜索文本
self.records: List[Record] = [] # 当前显示的记录列表
self.card_widgets: List[RecordCard] = [] # 卡片组件列表
self.setup_ui()
self.load_records()
def setup_ui(self):
"""设置UI布局"""
# 主布局
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(15)
# 1. 顶部工具栏
toolbar = self.create_toolbar()
main_layout.addWidget(toolbar)
# 2. 分类筛选栏
category_bar = self.create_category_bar()
main_layout.addWidget(category_bar)
# 3. 记录列表(卡片网格)
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll_area.setStyleSheet("""
QScrollArea {
border: none;
background-color: transparent;
}
""")
# 卡片容器
self.cards_container = QWidget()
self.cards_layout = QVBoxLayout(self.cards_container)
self.cards_layout.setContentsMargins(10, 10, 10, 10)
self.cards_layout.setSpacing(15)
self.cards_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
self.scroll_area.setWidget(self.cards_container)
main_layout.addWidget(self.scroll_area)
# 设置样式
self.setStyleSheet("""
BrowseView {
background-color: #F5F7FA;
}
""")
def create_toolbar(self) -> QFrame:
"""创建工具栏"""
frame = QFrame()
frame.setStyleSheet("""
QFrame {
background-color: white;
border-radius: 12px;
padding: 15px;
}
""")
layout = QHBoxLayout(frame)
layout.setSpacing(15)
# 标题
title_label = QLabel("浏览记录")
title_label.setStyleSheet("""
font-size: 24px;
font-weight: bold;
color: #2C3E50;
""")
layout.addWidget(title_label)
layout.addStretch()
# 搜索框
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("搜索记录...")
self.search_input.setMinimumWidth(250)
self.search_input.setMaximumWidth(400)
self.search_input.setStyleSheet("""
QLineEdit {
padding: 10px 15px;
border: 2px solid #E0E0E0;
border-radius: 20px;
font-size: 14px;
background-color: #FAFAFA;
}
QLineEdit:focus {
border: 2px solid #4A90E2;
background-color: white;
}
""")
self.search_input.textChanged.connect(self.on_search_text_changed)
layout.addWidget(self.search_input)
# 刷新按钮
refresh_btn = QPushButton("刷新")
refresh_btn.setStyleSheet("""
QPushButton {
background-color: #4A90E2;
color: white;
border: none;
padding: 10px 20px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
QPushButton:hover {
background-color: #357ABD;
}
QPushButton:pressed {
background-color: #2E6FA8;
}
""")
refresh_btn.clicked.connect(self.load_records)
layout.addWidget(refresh_btn)
return frame
def create_category_bar(self) -> QFrame:
"""创建分类筛选栏"""
frame = QFrame()
frame.setStyleSheet("""
QFrame {
background-color: white;
border-radius: 12px;
padding: 15px;
}
""")
layout = QHBoxLayout(frame)
layout.setSpacing(10)
# 全部
self.all_btn = self.create_category_button("全部", "ALL", checked=True)
self.all_btn.clicked.connect(lambda: self.filter_by_category("ALL"))
layout.addWidget(self.all_btn)
# 各个分类
categories = [
("待办", "TODO", "#5DADE2"),
("笔记", "NOTE", "#58D68D"),
("灵感", "IDEA", "#F5B041"),
("参考", "REF", "#AF7AC5"),
("趣味", "FUNNY", "#EC7063"),
("文本", "TEXT", "#95A5A6"),
]
self.category_buttons = {}
for name, code, color in categories:
btn = self.create_category_button(name, code, color)
btn.clicked.connect(lambda checked, c=code: self.filter_by_category(c))
layout.addWidget(btn)
self.category_buttons[code] = btn
layout.addStretch()
# 统计标签
self.stats_label = QLabel()
self.stats_label.setStyleSheet("""
font-size: 14px;
color: #666;
""")
layout.addWidget(self.stats_label)
return frame
def create_category_button(self, text: str, category_code: str,
color: str = None, checked: bool = False) -> QPushButton:
"""
创建分类按钮
Args:
text: 按钮文本
category_code: 分类代码
color: 分类颜色
checked: 是否选中
Returns:
QPushButton对象
"""
btn = QPushButton(text)
btn.setCheckable(True)
btn.setChecked(checked)
# 设置按钮样式
if color:
btn.setStyleSheet(f"""
QPushButton {{
background-color: {color};
color: white;
border: none;
padding: 10px 25px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}}
QPushButton:hover {{
opacity: 0.8;
}}
QPushButton:checked {{
border: 3px solid #2C3E50;
}}
""")
else:
btn.setStyleSheet("""
QPushButton {
background-color: #34495E;
color: white;
border: none;
padding: 10px 25px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
QPushButton:hover {
opacity: 0.8;
}
QPushButton:checked {
border: 3px solid #4A90E2;
}
""")
return btn
def load_records(self):
"""从数据库加载记录"""
try:
session = get_db()
# 查询记录
query = session.query(Record).order_by(Record.created_at.desc())
# 应用分类筛选
if self.current_category != "ALL":
query = query.filter(Record.category == self.current_category)
# 应用搜索筛选
if self.search_text:
search_pattern = f"%{self.search_text}%"
query = query.filter(
(Record.ocr_text.like(search_pattern)) |
(Record.ai_result.like(search_pattern)) |
(Record.notes.like(search_pattern))
)
self.records = query.all()
# 更新统计
self.update_stats()
# 渲染卡片
self.render_cards()
session.close()
except Exception as e:
QMessageBox.critical(self, "错误", f"加载记录失败: {str(e)}")
def render_cards(self):
"""渲染记录卡片"""
# 清空现有卡片
for card in self.card_widgets:
card.deleteLater()
self.card_widgets.clear()
# 如果没有记录
if not self.records:
empty_label = QLabel("没有找到记录")
empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
empty_label.setStyleSheet("""
font-size: 18px;
color: #999;
padding: 50px;
""")
self.cards_layout.addWidget(empty_label)
return
# 创建卡片网格布局
from PyQt6.QtWidgets import QGridLayout
grid_widget = QWidget()
grid_layout = QGridLayout(grid_widget)
grid_layout.setSpacing(15)
# 计算列数每行最多4个
columns = 4
row, col = 0, 0
for record in self.records:
card = RecordCard(
record_id=record.id,
image_path=record.image_path,
ocr_text=record.ocr_text or "",
category=record.category,
created_at=record.created_at
)
card.clicked.connect(self.open_record_detail)
self.card_widgets.append(card)
grid_layout.addWidget(card, row, col)
col += 1
if col >= columns:
col = 0
row += 1
# 清空原有布局并添加新的网格
while self.cards_layout.count():
item = self.cards_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self.cards_layout.addWidget(grid_widget)
def update_stats(self):
"""更新统计信息"""
total_count = len(self.records)
category_name = self.current_category if self.current_category != "ALL" else "全部"
if self.current_category == "ALL":
self.stats_label.setText(f"{total_count} 条记录")
else:
category_names = {
"TODO": "待办",
"NOTE": "笔记",
"IDEA": "灵感",
"REF": "参考",
"FUNNY": "趣味",
"TEXT": "文本",
}
cn_name = category_names.get(self.current_category, self.current_category)
self.stats_label.setText(f"{cn_name}: {total_count}")
def filter_by_category(self, category: str):
"""
按分类筛选
Args:
category: 分类代码,"ALL"表示全部
"""
self.current_category = category
# 更新按钮状态
if category == "ALL":
self.all_btn.setChecked(True)
for btn in self.category_buttons.values():
btn.setChecked(False)
else:
self.all_btn.setChecked(False)
for code, btn in self.category_buttons.items():
btn.setChecked(code == category)
# 重新加载记录
self.load_records()
def on_search_text_changed(self, text: str):
"""
搜索文本改变
Args:
text: 搜索文本
"""
self.search_text = text
# 使用定时器延迟搜索(避免频繁查询)
if hasattr(self, '_search_timer'):
self._search_timer.stop()
self._search_timer = QTimer()
self._search_timer.setSingleShot(True)
self._search_timer.timeout.connect(self.load_records)
self._search_timer.start(300) # 300ms延迟
def open_record_detail(self, record_id: int):
"""
打开记录详情
Args:
record_id: 记录ID
"""
try:
session = get_db()
record = session.query(Record).filter(Record.id == record_id).first()
if not record:
QMessageBox.warning(self, "警告", "记录不存在")
session.close()
return
# 创建详情对话框
dialog = RecordDetailDialog(
record_id=record.id,
image_path=record.image_path,
ocr_text=record.ocr_text or "",
category=record.category,
ai_result=record.ai_result,
tags=record.tags,
notes=record.notes,
created_at=record.created_at,
updated_at=record.updated_at,
parent=self
)
# 显示对话框
result = dialog.exec()
if result == QDialog.DialogCode.Accepted:
# 获取修改后的数据
data = dialog.get_data()
if data.get('modified'):
# 保存修改到数据库
record.category = data['category']
record.notes = data['notes']
session.commit()
# 发出信号
self.record_modified.emit(record_id)
# 刷新列表
self.load_records()
session.close()
except Exception as e:
QMessageBox.critical(self, "错误", f"打开详情失败: {str(e)}")
def refresh(self):
"""刷新记录列表"""
self.load_records()

View File

@@ -1,381 +0,0 @@
"""
剪贴板监听组件
实现剪贴板变化监听,自动检测图片内容:
- 监听剪贴板变化
- 自动检测图片内容
- 发出图片检测信号
"""
from PyQt6.QtWidgets import QApplication, QWidget
from PyQt6.QtCore import QObject, pyqtSignal, QTimer
from PyQt6.QtGui import QClipboard, QPixmap, QImage
from pathlib import Path
from datetime import datetime
import tempfile
from typing import Optional
class ClipboardMonitor(QObject):
"""
剪贴板监听器
监听系统剪贴板的变化,自动检测图片内容
"""
# 信号:检测到图片时发出,传递图片路径
image_detected = pyqtSignal(str)
# 信号:剪贴板内容变化时发出,传递是否有图片
clipboard_changed = pyqtSignal(bool)
def __init__(self, parent: Optional[QObject] = None):
"""
初始化剪贴板监听器
Args:
parent: 父对象
"""
super().__init__(parent)
# 获取剪贴板
self.clipboard = QApplication.clipboard()
# 监听剪贴板变化
self.clipboard.dataChanged.connect(self._on_clipboard_changed)
# 记录上次的图片数据,避免重复触发
self.last_image_data = None
# 临时保存目录
self.temp_dir = Path(tempfile.gettempdir()) / "cutthenthink" / "clipboard"
self.temp_dir.mkdir(parents=True, exist_ok=True)
# 监听开关
self._enabled = True
def _on_clipboard_changed(self):
"""剪贴板内容变化处理"""
if not self._enabled:
return
# 检查剪贴板是否有图片
pixmap = self.clipboard.pixmap()
if not pixmap.isNull():
# 有图片
# 检查是否是新的图片(避免重复触发)
image_data = self._get_image_data(pixmap)
if image_data != self.last_image_data:
self.last_image_data = image_data
# 保存图片
filepath = self._save_clipboard_image(pixmap)
if filepath:
self.image_detected.emit(filepath)
self.clipboard_changed.emit(True)
else:
# 无图片
self.last_image_data = None
self.clipboard_changed.emit(False)
def _get_image_data(self, pixmap: QPixmap) -> bytes:
"""
获取图片数据(用于比较)
Args:
pixmap: 图片对象
Returns:
图片的字节数据
"""
from io import BytesIO
buffer = BytesIO()
# 保存为 PNG 格式到内存
pixmap.save(buffer, "PNG")
return buffer.getvalue()
def _save_clipboard_image(self, pixmap: QPixmap) -> Optional[str]:
"""
保存剪贴板图片
Args:
pixmap: 图片对象
Returns:
保存的文件路径,失败返回 None
"""
try:
# 生成文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"clipboard_{timestamp}.png"
filepath = self.temp_dir / filename
# 保存图片
if pixmap.save(str(filepath)):
return str(filepath)
except Exception as e:
print(f"保存剪贴板图片失败: {e}")
return None
def is_enabled(self) -> bool:
"""
检查监听是否启用
Returns:
True 表示启用False 表示禁用
"""
return self._enabled
def set_enabled(self, enabled: bool):
"""
设置监听状态
Args:
enabled: True 启用False 禁用
"""
self._enabled = enabled
def enable(self):
"""启用监听"""
self.set_enabled(True)
def disable(self):
"""禁用监听"""
self.set_enabled(False)
def has_image(self) -> bool:
"""
检查剪贴板当前是否有图片
Returns:
True 表示有图片False 表示无图片
"""
pixmap = self.clipboard.pixmap()
return not pixmap.isNull()
def get_image(self) -> Optional[QPixmap]:
"""
获取剪贴板中的图片
Returns:
图片对象,无图片时返回 None
"""
pixmap = self.clipboard.pixmap()
if pixmap.isNull():
return None
return pixmap
def save_current_image(self, filepath: str) -> bool:
"""
保存当前剪贴板图片
Args:
filepath: 保存路径
Returns:
成功返回 True失败返回 False
"""
pixmap = self.get_image()
if pixmap is None:
return False
try:
return pixmap.save(filepath)
except Exception as e:
print(f"保存图片失败: {e}")
return False
def clear_history(self):
"""清空临时保存的剪贴板图片历史"""
try:
import shutil
if self.temp_dir.exists():
shutil.rmtree(self.temp_dir)
self.temp_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
print(f"清空剪贴板历史失败: {e}")
class ClipboardImagePicker(QWidget):
"""
剪贴板图片选择器
提供图形界面,显示剪贴板中的图片并允许用户操作
"""
# 信号:用户选择使用图片时发出,传递图片路径
image_selected = pyqtSignal(str)
# 信号:用户取消选择时发出
selection_cancelled = pyqtSignal()
def __init__(self, parent: Optional[QWidget] = None):
"""
初始化剪贴板图片选择器
Args:
parent: 父组件
"""
super().__init__(parent)
self.clipboard = QApplication.clipboard()
self.current_image_path = None
self._init_ui()
self._check_clipboard()
def _init_ui(self):
"""初始化UI"""
from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QPushButton
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
# 标题
title = QLabel("剪贴板图片")
title.setStyleSheet("""
QLabel {
font-size: 16px;
font-weight: bold;
color: #333333;
}
""")
layout.addWidget(title)
# 图片预览
self.preview_label = QLabel()
self.preview_label.setMinimumSize(400, 300)
self.preview_label.setAlignment(
Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter
)
self.preview_label.setStyleSheet("""
QLabel {
background-color: #F5F5F5;
border: 2px dashed #CCCCCC;
border-radius: 8px;
color: #999999;
font-size: 14px;
}
""")
self.preview_label.setText("剪贴板中没有图片")
layout.addWidget(self.preview_label)
# 按钮区域
button_layout = QHBoxLayout()
button_layout.setSpacing(12)
# 刷新按钮
self.refresh_btn = QPushButton("🔄 刷新")
self.refresh_btn.setMinimumHeight(36)
self.refresh_btn.clicked.connect(self._check_clipboard)
button_layout.addWidget(self.refresh_btn)
button_layout.addStretch()
# 使用按钮
self.use_btn = QPushButton("✓ 使用图片")
self.use_btn.setMinimumHeight(36)
self.use_btn.setEnabled(False)
self.use_btn.clicked.connect(self._on_use_image)
button_layout.addWidget(self.use_btn)
# 取消按钮
self.cancel_btn = QPushButton("✕ 取消")
self.cancel_btn.setMinimumHeight(36)
self.cancel_btn.clicked.connect(self._on_cancel)
button_layout.addWidget(self.cancel_btn)
layout.addLayout(button_layout)
# 应用样式
self.setStyleSheet("""
QPushButton {
background-color: #4A90E2;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
padding: 8px 16px;
}
QPushButton:hover {
background-color: #357ABD;
}
QPushButton:pressed {
background-color: #2A639D;
}
QPushButton:disabled {
background-color: #CCCCCC;
color: #666666;
}
""")
def _check_clipboard(self):
"""检查剪贴板中的图片"""
pixmap = self.clipboard.pixmap()
if pixmap.isNull():
# 无图片
self.preview_label.setText("剪贴板中没有图片")
self.preview_label.setPixmap(QPixmap())
self.use_btn.setEnabled(False)
self.current_image_path = None
else:
# 有图片
# 缩放预览
scaled_pixmap = pixmap.scaled(
380, 280,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
self.preview_label.setPixmap(scaled_pixmap)
self.preview_label.setText("")
# 保存到临时文件
self.current_image_path = self._save_temp_image(pixmap)
self.use_btn.setEnabled(True)
def _save_temp_image(self, pixmap: QPixmap) -> Optional[str]:
"""
保存图片到临时文件
Args:
pixmap: 图片对象
Returns:
保存的文件路径
"""
try:
temp_dir = Path(tempfile.gettempdir()) / "cutthenthink" / "clipboard"
temp_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"clipboard_{timestamp}.png"
filepath = temp_dir / filename
if pixmap.save(str(filepath)):
return str(filepath)
except Exception as e:
print(f"保存临时图片失败: {e}")
return None
def _on_use_image(self):
"""使用图片按钮点击处理"""
if self.current_image_path:
self.image_selected.emit(self.current_image_path)
def _on_cancel(self):
"""取消按钮点击处理"""
self.selection_cancelled.emit()
def refresh(self):
"""刷新剪贴板检查"""
self._check_clipboard()

View File

@@ -1,472 +0,0 @@
"""
图片文件选择组件
实现图片文件选择功能,包括:
- 文件对话框选择
- 拖放支持
- 支持的图片格式过滤
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QFileDialog, QFrame, QMessageBox
)
from PyQt6.QtCore import Qt, pyqtSignal, QMimeData
from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QPixmap, QCursor
from pathlib import Path
from typing import Optional, List
class ImagePicker(QWidget):
"""
图片选择器组件
提供多种方式选择图片:
- 点击按钮打开文件对话框
- 拖放文件到组件
"""
# 信号:图片选择完成时发出,传递图片路径列表
images_selected = pyqtSignal(list)
# 信号:单个图片选择完成时发出,传递图片路径
image_selected = pyqtSignal(str)
# 信号:取消选择
selection_cancelled = pyqtSignal()
# 支持的图片格式
SUPPORTED_FORMATS = [
"图片文件 (*.png *.jpg *.jpeg *.bmp *.gif *.webp *.tiff)",
"PNG 文件 (*.png)",
"JPEG 文件 (*.jpg *.jpeg)",
"位图文件 (*.bmp)",
"GIF 文件 (*.gif)",
"WebP 文件 (*.webp)",
"所有文件 (*.*)"
]
def __init__(self, multiple: bool = False, parent: Optional[QWidget] = None):
"""
初始化图片选择器
Args:
multiple: 是否允许多选
parent: 父组件
"""
super().__init__(parent)
self.multiple = multiple
self.selected_paths = []
# 启用拖放
self.setAcceptDrops(True)
self._init_ui()
def _init_ui(self):
"""初始化UI"""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# 创建拖放区域
self.drop_area = DropArea(self.multiple, self)
self.drop_area.images_dropped.connect(self._on_images_dropped)
layout.addWidget(self.drop_area)
# 创建按钮区域
button_layout = QHBoxLayout()
button_layout.setSpacing(12)
# 选择文件按钮
self.select_btn = QPushButton("📂 选择图片")
self.select_btn.setMinimumHeight(40)
self.select_btn.clicked.connect(self._on_select_clicked)
button_layout.addWidget(self.select_btn)
# 清除按钮
self.clear_btn = QPushButton("🗑️ 清除")
self.clear_btn.setMinimumHeight(40)
self.clear_btn.setEnabled(False)
self.clear_btn.clicked.connect(self._on_clear_clicked)
button_layout.addWidget(self.clear_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
# 应用样式
self._apply_styles()
def _apply_styles(self):
"""应用样式"""
self.setStyleSheet("""
QPushButton {
background-color: #4A90E2;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
padding: 10px 20px;
}
QPushButton:hover {
background-color: #357ABD;
}
QPushButton:pressed {
background-color: #2A639D;
}
QPushButton:disabled {
background-color: #CCCCCC;
color: #666666;
}
""")
def _on_select_clicked(self):
"""选择按钮点击处理"""
if self.multiple:
# 多选
filepaths, _ = QFileDialog.getOpenFileNames(
self,
"选择图片",
str(Path.home()),
";;".join(self.SUPPORTED_FORMATS)
)
if filepaths:
self._on_images_dropped(filepaths)
else:
# 单选
filepath, _ = QFileDialog.getOpenFileName(
self,
"选择图片",
str(Path.home()),
";;".join(self.SUPPORTED_FORMATS)
)
if filepath:
self._on_images_dropped([filepath])
def _on_clear_clicked(self):
"""清除按钮点击处理"""
self.selected_paths.clear()
self.drop_area.clear_previews()
self.clear_btn.setEnabled(False)
def _on_images_dropped(self, paths: List[str]):
"""
图片拖放或选择完成处理
Args:
paths: 图片路径列表
"""
# 过滤有效图片
valid_paths = self._filter_valid_images(paths)
if not valid_paths:
QMessageBox.warning(
self,
"无效文件",
"所选文件不是有效的图片格式"
)
return
if self.multiple:
# 多选模式:添加到列表
self.selected_paths.extend(valid_paths)
self.images_selected.emit(self.selected_paths)
else:
# 单选模式:只保留最后一个
self.selected_paths = valid_paths[-1:]
if valid_paths:
self.image_selected.emit(valid_paths[0])
# 更新预览
self.drop_area.show_previews(valid_paths)
self.clear_btn.setEnabled(True)
def _filter_valid_images(self, paths: List[str]) -> List[str]:
"""
过滤有效的图片文件
Args:
paths: 文件路径列表
Returns:
有效的图片路径列表
"""
valid_extensions = {'.png', '.jpg', '.jpeg', '.bmp',
'.gif', '.webp', '.tiff', '.tif'}
valid_paths = []
for path in paths:
filepath = Path(path)
if filepath.suffix.lower() in valid_extensions and filepath.exists():
valid_paths.append(str(filepath))
return valid_paths
def get_selected_images(self) -> List[str]:
"""
获取已选择的图片路径
Returns:
图片路径列表
"""
return self.selected_paths.copy()
def clear_selection(self):
"""清除选择"""
self._on_clear_clicked()
class DropArea(QFrame):
"""
拖放区域组件
显示拖放提示和图片预览
"""
# 信号:图片拖放完成,传递路径列表
images_dropped = pyqtSignal(list)
def __init__(self, multiple: bool = False, parent: Optional[QWidget] = None):
"""
初始化拖放区域
Args:
multiple: 是否支持多张图片
parent: 父组件
"""
super().__init__(parent)
self.multiple = multiple
self.preview_labels = []
self._init_ui()
def _init_ui(self):
"""初始化UI"""
self.setAcceptDrops(True)
self.setMinimumHeight(200)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
# 提示标签
self.hint_label = QLabel()
self.update_hint()
self.hint_label.setAlignment(
Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter
)
layout.addWidget(self.hint_label)
# 预览容器
self.preview_container = QWidget()
self.preview_layout = QVBoxLayout(self.preview_container)
self.preview_layout.setContentsMargins(0, 0, 0, 0)
self.preview_layout.setSpacing(10)
layout.addWidget(self.preview_container)
# 应用样式
self._apply_styles()
def _apply_styles(self):
"""应用样式"""
self.setStyleSheet("""
DropArea {
background-color: #F9F9F9;
border: 2px dashed #CCCCCC;
border-radius: 12px;
}
DropArea:hover {
background-color: #F0F8FF;
border: 2px dashed #4A90E2;
}
""")
self.hint_label.setStyleSheet("""
QLabel {
color: #666666;
font-size: 16px;
}
""")
def update_hint(self):
"""更新提示文本"""
if self.multiple:
hint = "🖼️ 拖放图片到此处\n或点击下方按钮选择"
else:
hint = "🖼️ 拖放图片到此处\n或点击下方按钮选择"
self.hint_label.setText(hint)
def dragEnterEvent(self, event: QDragEnterEvent):
"""拖拽进入事件"""
if event.mimeData().hasUrls():
event.acceptProposedAction()
self.setStyleSheet("""
DropArea {
background-color: #E6F2FF;
border: 2px dashed #4A90E2;
border-radius: 12px;
}
""")
def dragLeaveEvent(self, event):
"""拖拽离开事件"""
self._apply_styles()
def dropEvent(self, event: QDropEvent):
"""拖放事件"""
self._apply_styles()
mime_data = event.mimeData()
if mime_data.hasUrls():
# 获取文件路径
paths = []
for url in mime_data.urls():
if url.isLocalFile():
paths.append(url.toLocalFile())
if paths:
self.images_dropped.emit(paths)
def show_previews(self, paths: List[str]):
"""
显示图片预览
Args:
paths: 图片路径列表
"""
# 清除旧预览
self.clear_previews()
if not self.multiple and len(paths) > 0:
# 单选模式只显示第一个
paths = [paths[0]]
for path in paths:
# 创建预览标签
preview_label = ImagePreviewLabel(path, self)
self.preview_layout.addWidget(preview_label)
self.preview_labels.append(preview_label)
# 隐藏提示
self.hint_label.hide()
def clear_previews(self):
"""清除所有预览"""
for label in self.preview_labels:
label.deleteLater()
self.preview_labels.clear()
self.hint_label.show()
class ImagePreviewLabel(QLabel):
"""
图片预览标签
显示单个图片的预览
"""
def __init__(self, image_path: str, parent: Optional[QWidget] = None):
"""
初始化预览标签
Args:
image_path: 图片路径
parent: 父组件
"""
super().__init__(parent)
self.image_path = image_path
self._load_preview()
def _load_preview(self):
"""加载图片预览"""
try:
pixmap = QPixmap(self.image_path)
if not pixmap.isNull():
# 缩放到合适大小
scaled_pixmap = pixmap.scaled(
560, 315,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
self.setPixmap(scaled_pixmap)
self.setAlignment(
Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter
)
# 显示文件名
filename = Path(self.image_path).name
self.setToolTip(filename)
# 设置样式
self.setStyleSheet("""
QLabel {
background-color: white;
border: 1px solid #E0E0E0;
border-radius: 8px;
padding: 10px;
}
""")
self.setMinimumHeight(100)
except Exception as e:
self.setText(f"加载失败: {Path(self.image_path).name}")
self.setStyleSheet("""
QLabel {
color: #FF0000;
font-size: 14px;
}
""")
class QuickImagePicker:
"""
快速图片选择器助手
提供静态方法快速选择图片
"""
@staticmethod
def pick_single_image(parent: Optional[QWidget] = None) -> Optional[str]:
"""
选择单个图片(同步对话框)
Args:
parent: 父组件
Returns:
选择的图片路径,取消返回 None
"""
filepath, _ = QFileDialog.getOpenFileName(
parent,
"选择图片",
str(Path.home()),
";;".join(ImagePicker.SUPPORTED_FORMATS)
)
return filepath if filepath else None
@staticmethod
def pick_multiple_images(parent: Optional[QWidget] = None) -> List[str]:
"""
选择多个图片(同步对话框)
Args:
parent: 父组件
Returns:
选择的图片路径列表
"""
filepaths, _ = QFileDialog.getOpenFileNames(
parent,
"选择图片",
str(Path.home()),
";;".join(ImagePicker.SUPPORTED_FORMATS)
)
return filepaths

View File

@@ -1,504 +0,0 @@
"""
图片预览组件
实现图片预览功能,包括:
- 图片显示和缩放
- 缩放控制
- 旋转功能
- 全屏查看
- 信息显示
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QScrollArea, QFrame, QSizePolicy, QSlider,
QApplication, QMessageBox
)
from PyQt6.QtCore import Qt, QPoint, pyqtSignal, QSize, QRect
from PyQt6.QtGui import QPixmap, QPainter, QCursor, QAction, QImage, QKeySequence
from pathlib import Path
from typing import Optional
from enum import Enum
class ZoomMode(str, Enum):
"""缩放模式"""
FIT = "fit" # 适应窗口
FILL = "fill" # 填充窗口
ACTUAL = "actual" # 实际大小
CUSTOM = "custom" # 自定义缩放
class ImagePreviewWidget(QWidget):
"""
图片预览组件
提供完整的图片预览功能,包括缩放、旋转、平移等
"""
# 信号:图片加载完成时发出
image_loaded = pyqtSignal(str)
# 信号:图片加载失败时发出
image_load_failed = pyqtSignal(str)
def __init__(self, parent: Optional[QWidget] = None):
"""
初始化图片预览组件
Args:
parent: 父组件
"""
super().__init__(parent)
# 图片相关
self.original_pixmap: Optional[QPixmap] = None
self.current_pixmap: Optional[QPixmap] = None
self.image_path = ""
# 显示参数
self.zoom_factor = 1.0
self.rotation_angle = 0
self.min_zoom = 0.1
self.max_zoom = 10.0
self.zoom_mode = ZoomMode.FIT
# 拖动平移
self.drag_start_pos: Optional[QPoint] = None
self.scroll_start_pos: Optional[QPoint] = None
self._init_ui()
def _init_ui(self):
"""初始化UI"""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# 创建工具栏
self._create_toolbar(layout)
# 创建滚动区域
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setAlignment(
Qt.AlignmentFlag.AlignCenter |
Qt.AlignmentFlag.AlignVCenter
)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# 创建图片标签
self.image_label = ImageLabel()
self.image_label.setAlignment(
Qt.AlignmentFlag.AlignCenter |
Qt.AlignmentFlag.AlignVCenter
)
self.image_label.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding
)
# 连接信号
self.image_label.drag_started.connect(self._on_drag_started)
self.image_label.dragged.connect(self._on_dragged)
self.image_label.drag_finished.connect(self._on_drag_finished)
self.scroll_area.setWidget(self.image_label)
layout.addWidget(self.scroll_area)
# 创建缩放滑块
self._create_zoom_slider(layout)
# 应用样式
self._apply_styles()
# 显示占位符
self._show_placeholder()
def _create_toolbar(self, parent_layout: QVBoxLayout):
"""创建工具栏"""
toolbar = QWidget()
toolbar.setObjectName("previewToolbar")
toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(12, 8, 12, 8)
toolbar_layout.setSpacing(8)
# 放大按钮
self.zoom_in_btn = QPushButton("🔍+")
self.zoom_in_btn.setToolTip("放大 (Ctrl++)")
self.zoom_in_btn.setMinimumSize(36, 36)
self.zoom_in_btn.clicked.connect(self.zoom_in)
toolbar_layout.addWidget(self.zoom_in_btn)
# 缩小按钮
self.zoom_out_btn = QPushButton("🔍-")
self.zoom_out_btn.setToolTip("缩小 (Ctrl+-)")
self.zoom_out_btn.setMinimumSize(36, 36)
self.zoom_out_btn.clicked.connect(self.zoom_out)
toolbar_layout.addWidget(self.zoom_out_btn)
# 适应按钮
self.fit_btn = QPushButton("📐 适应")
self.fit_btn.setToolTip("适应窗口 (Ctrl+F)")
self.fit_btn.setMinimumSize(60, 36)
self.fit_btn.clicked.connect(self.fit_to_window)
toolbar_layout.addWidget(self.fit_btn)
# 实际大小按钮
self.actual_btn = QPushButton("1:1")
self.actual_btn.setToolTip("实际大小 (Ctrl+0)")
self.actual_btn.setMinimumSize(60, 36)
self.actual_btn.clicked.connect(self.actual_size)
toolbar_layout.addWidget(self.actual_btn)
toolbar_layout.addStretch()
# 左旋转按钮
self.rotate_left_btn = QPushButton("")
self.rotate_left_btn.setToolTip("向左旋转 (Ctrl+L)")
self.rotate_left_btn.setMinimumSize(36, 36)
self.rotate_left_btn.clicked.connect(lambda: self.rotate(-90))
toolbar_layout.addWidget(self.rotate_left_btn)
# 右旋转按钮
self.rotate_right_btn = QPushButton("")
self.rotate_right_btn.setToolTip("向右旋转 (Ctrl+R)")
self.rotate_right_btn.setMinimumSize(36, 36)
self.rotate_right_btn.clicked.connect(lambda: self.rotate(90))
toolbar_layout.addWidget(self.rotate_right_btn)
# 全屏按钮
self.fullscreen_btn = QPushButton("")
self.fullscreen_btn.setToolTip("全屏 (F11)")
self.fullscreen_btn.setMinimumSize(36, 36)
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
toolbar_layout.addWidget(self.fullscreen_btn)
# 应用工具栏样式
toolbar.setStyleSheet("""
QWidget#previewToolbar {
background-color: #F5F5F5;
border-bottom: 1px solid #E0E0E0;
}
""")
parent_layout.addWidget(toolbar)
def _create_zoom_slider(self, parent_layout: QVBoxLayout):
"""创建缩放滑块"""
slider_container = QWidget()
slider_layout = QHBoxLayout(slider_container)
slider_layout.setContentsMargins(12, 4, 12, 8)
slider_layout.setSpacing(8)
# 缩放百分比标签
self.zoom_label = QLabel("100%")
self.zoom_label.setMinimumWidth(60)
slider_layout.addWidget(self.zoom_label)
# 缩放滑块
self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
self.zoom_slider.setMinimum(10) # 10%
self.zoom_slider.setMaximum(1000) # 1000%
self.zoom_slider.setValue(100)
self.zoom_slider.valueChanged.connect(self._on_slider_changed)
slider_layout.addWidget(self.zoom_slider)
slider_container.setMaximumHeight(50)
parent_layout.addWidget(slider_container)
def _apply_styles(self):
"""应用样式"""
self.setStyleSheet("""
QPushButton {
background-color: #4A90E2;
color: white;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background-color: #357ABD;
}
QPushButton:pressed {
background-color: #2A639D;
}
QPushButton:disabled {
background-color: #CCCCCC;
color: #666666;
}
QScrollArea {
background-color: #2C2C2C;
border: none;
}
QLabel {
color: #666666;
font-size: 13px;
}
""")
def _show_placeholder(self):
"""显示占位符"""
self.image_label.setText("""
<div style='color: #999999; font-size: 16px;'>
<p style='text-align: center;'>🖼️</p>
<p style='text-align: center;'>暂无图片</p>
<p style='text-align: center; font-size: 13px;'>
请选择或拖入图片
</p>
</div>
""")
def load_image(self, image_path: str) -> bool:
"""
加载图片
Args:
image_path: 图片路径
Returns:
成功返回 True失败返回 False
"""
try:
path = Path(image_path)
if not path.exists():
self.image_load_failed.emit(f"文件不存在: {image_path}")
return False
# 加载图片
pixmap = QPixmap(str(path))
if pixmap.isNull():
self.image_load_failed.emit(f"无法加载图片: {image_path}")
return False
self.original_pixmap = pixmap
self.current_pixmap = QPixmap(pixmap)
self.image_path = image_path
# 重置显示参数
self.rotation_angle = 0
self.zoom_mode = ZoomMode.FIT
self.fit_to_window()
self.image_loaded.emit(image_path)
return True
except Exception as e:
self.image_load_failed.emit(f"加载失败: {str(e)}")
return False
def load_pixmap(self, pixmap: QPixmap) -> bool:
"""
加载 QPixmap 对象
Args:
pixmap: 图片对象
Returns:
成功返回 True失败返回 False
"""
try:
if pixmap.isNull():
return False
self.original_pixmap = QPixmap(pixmap)
self.current_pixmap = QPixmap(pixmap)
self.image_path = ""
# 重置显示参数
self.rotation_angle = 0
self.zoom_mode = ZoomMode.FIT
self.fit_to_window()
return True
except Exception as e:
return False
def update_display(self):
"""更新显示"""
if self.current_pixmap is None:
return
# 应用旋转
rotated_pixmap = self.current_pixmap.transformed(
self.current_pixmap.transform().rotate(self.rotation_angle),
Qt.TransformationMode.SmoothTransformation
)
# 应用缩放
if self.zoom_mode == ZoomMode.FIT:
scaled_pixmap = rotated_pixmap.scaled(
self.scroll_area.viewport().size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
self.zoom_factor = scaled_pixmap.width() / rotated_pixmap.width()
elif self.zoom_mode == ZoomMode.CUSTOM:
scaled_pixmap = rotated_pixmap.scaled(
int(rotated_pixmap.width() * self.zoom_factor),
int(rotated_pixmap.height() * self.zoom_factor),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
else: # ACTUAL
scaled_pixmap = rotated_pixmap
self.zoom_factor = 1.0
self.image_label.setPixmap(scaled_pixmap)
self._update_zoom_label()
def _update_zoom_label(self):
"""更新缩放标签"""
zoom_percent = int(self.zoom_factor * 100)
self.zoom_label.setText(f"{zoom_percent}%")
self.zoom_slider.blockSignals(True)
self.zoom_slider.setValue(zoom_percent)
self.zoom_slider.blockSignals(False)
def zoom_in(self):
"""放大"""
self.zoom_mode = ZoomMode.CUSTOM
self.zoom_factor = min(self.zoom_factor * 1.2, self.max_zoom)
self.update_display()
def zoom_out(self):
"""缩小"""
self.zoom_mode = ZoomMode.CUSTOM
self.zoom_factor = max(self.zoom_factor / 1.2, self.min_zoom)
self.update_display()
def fit_to_window(self):
"""适应窗口"""
self.zoom_mode = ZoomMode.FIT
self.update_display()
def actual_size(self):
"""实际大小"""
self.zoom_mode = ZoomMode.ACTUAL
self.update_display()
def rotate(self, angle: int):
"""
旋转图片
Args:
angle: 旋转角度90 或 -90
"""
self.rotation_angle = (self.rotation_angle + angle) % 360
self.update_display()
def toggle_fullscreen(self):
"""切换全屏"""
window = self.window()
if window.isFullScreen():
window.showNormal()
else:
window.showFullScreen()
def _on_slider_changed(self, value: int):
"""缩放滑块值改变"""
self.zoom_mode = ZoomMode.CUSTOM
self.zoom_factor = value / 100.0
self.update_display()
def _on_drag_started(self, pos: QPoint):
"""拖动开始"""
self.drag_start_pos = pos
self.scroll_start_pos = QPoint(
self.scroll_area.horizontalScrollBar().value(),
self.scroll_area.verticalScrollBar().value()
)
self.image_label.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
def _on_dragged(self, pos: QPoint):
"""拖动中"""
if self.drag_start_pos and self.scroll_start_pos:
delta = pos - self.drag_start_pos
self.scroll_area.horizontalScrollBar().setValue(
self.scroll_start_pos.x() - delta.x()
)
self.scroll_area.verticalScrollBar().setValue(
self.scroll_start_pos.y() - delta.y()
)
def _on_drag_finished(self):
"""拖动结束"""
self.drag_start_pos = None
self.scroll_start_pos = None
self.image_label.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
def keyPressEvent(self, event):
"""键盘事件"""
# Ctrl++ 放大
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
if event.key() == Qt.Key.Key_Plus or event.key() == Qt.Key.Key_Equal:
self.zoom_in()
elif event.key() == Qt.Key.Key_Minus:
self.zoom_out()
elif event.key() == Qt.Key.Key_F:
self.fit_to_window()
elif event.key() == Qt.Key.Key_0:
self.actual_size()
elif event.key() == Qt.Key.Key_L:
self.rotate(-90)
elif event.key() == Qt.Key.Key_R:
self.rotate(90)
elif event.key() == Qt.Key.Key_F11:
self.toggle_fullscreen()
super().keyPressEvent(event)
def get_current_pixmap(self) -> Optional[QPixmap]:
"""获取当前显示的图片"""
return self.current_pixmap
def clear(self):
"""清除图片"""
self.original_pixmap = None
self.current_pixmap = None
self.image_path = ""
self.zoom_factor = 1.0
self.rotation_angle = 0
self._show_placeholder()
class ImageLabel(QLabel):
"""
可拖动的图片标签
支持鼠标拖动平移图片
"""
# 信号
drag_started = pyqtSignal(QPoint)
dragged = pyqtSignal(QPoint)
drag_finished = pyqtSignal()
def __init__(self, parent: Optional[QWidget] = None):
"""初始化图片标签"""
super().__init__(parent)
self.is_dragging = False
self.last_pos = QPoint()
def mousePressEvent(self, event):
"""鼠标按下事件"""
if event.button() == Qt.MouseButton.LeftButton and self.pixmap():
self.is_dragging = True
self.last_pos = event.pos()
self.drag_started.emit(event.pos())
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
"""鼠标移动事件"""
if self.is_dragging:
self.dragged.emit(event.pos())
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
"""鼠标释放事件"""
if event.button() == Qt.MouseButton.LeftButton:
self.is_dragging = False
self.drag_finished.emit()
super().mouseReleaseEvent(event)

View File

@@ -1,835 +0,0 @@
"""
错误提示和日志系统的 GUI 集成
提供统一的消息处理和错误显示功能
"""
import logging
from datetime import datetime
from typing import Optional, Callable, List, Dict, Any
# 尝试导入 tkinter失败时使用 PyQt6
try:
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
HAS_TKINTER = True
except ImportError:
HAS_TKINTER = False
# 使用 PyQt6 作为替代
from PyQt6.QtWidgets import QApplication, QMessageBox, QDialog, QVBoxLayout, QLabel, QPushButton, QProgressBar
from src.utils.logger import get_logger, LogCapture
logger = logging.getLogger(__name__)
class LogLevel:
"""日志级别"""
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
# PyQt6 替代实现(当 tkinter 不可用时)
class QtMessageHandler:
"""使用 PyQt6 的消息处理器"""
@staticmethod
def show_info(title: str, message: str, parent=None):
QMessageBox.information(parent, title, message)
@staticmethod
def show_warning(title: str, message: str, parent=None):
QMessageBox.warning(parent, title, message)
@staticmethod
def show_error(title: str, message: str, parent=None):
QMessageBox.critical(parent, title, message)
@staticmethod
def ask_yes_no(title: str, message: str, default: bool = True, parent=None) -> bool:
reply = QMessageBox.question(
parent, title, message,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes if default else QMessageBox.StandardButton.No
)
return reply == QMessageBox.StandardButton.Yes
@staticmethod
def ask_ok_cancel(title: str, message: str, default: bool = True, parent=None) -> bool:
reply = QMessageBox.question(
parent, title, message,
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Ok if default else QMessageBox.StandardButton.Cancel
)
return reply == QMessageBox.StandardButton.Ok
class MessageHandler:
"""
消息处理器
负责显示各种类型的消息和错误
"""
def __init__(self, parent=None):
"""
初始化消息处理器
Args:
parent: 父窗口
"""
self.parent = parent
self.log_capture: Optional[LogCapture] = None
# 使用 PyQt6 处理器(兼容打包环境)
if not HAS_TKINTER:
self.qt_handler = QtMessageHandler()
else:
self.qt_handler = None
def set_log_capture(self, capture: LogCapture):
"""
设置日志捕获器
Args:
capture: 日志捕获器
"""
self.log_capture = capture
def show_info(
self,
title: str,
message: str,
details: Optional[str] = None,
log: bool = True
):
"""
显示信息对话框
Args:
title: 标题
message: 消息内容
details: 详细信息(可选)
log: 是否记录到日志
"""
if log:
logger.info(message)
if details:
full_message = f"{message}\n\n详细信息:\n{details}"
else:
full_message = message
if not HAS_TKINTER:
self.qt_handler.show_info(title, full_message, self.parent)
else:
if self.parent:
messagebox.showinfo(title, full_message, parent=self.parent)
else:
messagebox.showinfo(title, full_message)
def show_warning(
self,
title: str,
message: str,
details: Optional[str] = None,
log: bool = True
):
"""
显示警告对话框
Args:
title: 标题
message: 消息内容
details: 详细信息(可选)
log: 是否记录到日志
"""
if log:
logger.warning(message)
if details:
full_message = f"{message}\n\n详细信息:\n{details}"
else:
full_message = message
if not HAS_TKINTER:
self.qt_handler.show_warning(title, full_message, self.parent)
else:
if self.parent:
messagebox.showwarning(title, full_message, parent=self.parent)
else:
messagebox.showwarning(title, full_message)
def show_error(
self,
title: str,
message: str,
details: Optional[str] = None,
exception: Optional[Exception] = None,
log: bool = True
):
"""
显示错误对话框
Args:
title: 标题
message: 消息内容
details: 详细信息(可选)
exception: 异常对象(可选)
log: 是否记录到日志
"""
if log:
logger.error(message, exc_info=exception is not None)
# 构建完整消息
full_message = message
if exception:
full_message += f"\n\n错误类型: {type(exception).__name__}"
if details:
full_message += f"\n\n详细信息:\n{details}"
if not HAS_TKINTER:
self.qt_handler.show_error(title, full_message, self.parent)
else:
if self.parent:
messagebox.showerror(title, full_message, parent=self.parent)
else:
messagebox.showerror(title, full_message)
def ask_yes_no(
self,
title: str,
message: str,
default: bool = True
) -> bool:
"""
询问是/否
Args:
title: 标题
message: 消息内容
default: 默认值True=是False=否)
Returns:
用户选择True=是False=否)
"""
if not HAS_TKINTER:
result = self.qt_handler.ask_yes_no(title, message, default, self.parent)
else:
if self.parent:
result = messagebox.askyesno(title, message, parent=self.parent, default=default)
else:
result = messagebox.askyesno(title, message, default=default)
logger.info(f"用户选择: {'' if result else ''} ({message})")
return result
def ask_ok_cancel(
self,
title: str,
message: str,
default: bool = True
) -> bool:
"""
询问确定/取消
Args:
title: 标题
message: 消息内容
default: 默认值True=确定False=取消)
Returns:
用户选择True=确定False=取消)
"""
if not HAS_TKINTER:
result = self.qt_handler.ask_ok_cancel(title, message, default, self.parent)
else:
if self.parent:
result = messagebox.askokcancel(title, message, parent=self.parent, default=default)
else:
result = messagebox.askokcancel(title, message, default=default)
logger.info(f"用户选择: {'确定' if result else '取消'} ({message})")
return result
def ask_retry_cancel(
self,
title: str,
message: str,
default: str = "retry"
) -> Optional[bool]:
"""
询问重试/取消
Args:
title: 标题
message: 消息内容
default: 默认选项 ("retry""cancel")
Returns:
用户选择True=重试False=取消None=关闭)
"""
if not HAS_TKINTER:
# PyQt6 版本使用简化的实现
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton
dialog = QDialog(self.parent)
dialog.setWindowTitle(title)
layout = QVBoxLayout()
label = QLabel(message)
layout.addWidget(label)
btn_layout = QHBoxLayout()
retry_btn = QPushButton("重试")
cancel_btn = QPushButton("取消")
if default == "retry":
retry_btn.setDefault(True)
else:
cancel_btn.setDefault(True)
btn_layout.addWidget(retry_btn)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
dialog.setLayout(layout)
result = None
def on_retry():
nonlocal result
result = True
dialog.accept()
def on_cancel():
nonlocal result
result = False
dialog.accept()
retry_btn.clicked.connect(on_retry)
cancel_btn.clicked.connect(on_cancel)
dialog.exec()
return result
else:
if self.parent:
result = messagebox.askretrycancel(title, message, parent=self.parent, default=default == "retry")
else:
result = messagebox.askretrycancel(title, message, default=default == "retry")
if result is True:
logger.info(f"用户选择: 重试 ({message})")
elif result is False:
logger.info(f"用户选择: 取消 ({message})")
else:
logger.info(f"用户选择: 关闭 ({message})")
return result
# PyQt6 进度对话框(替代 tkinter 版本)
class QtProgressDialog(QDialog):
"""PyQt6 进度对话框"""
def __init__(self, parent, title: str = "处理中", message: str = "请稍候...", cancelable: bool = False):
super().__init__(parent)
self.setWindowTitle(title)
self.setFixedSize(400, 150)
self.setModal(True)
layout = QVBoxLayout()
self.message_label = QLabel(message)
layout.addWidget(self.message_label)
from PyQt6.QtWidgets import QProgressBar
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 0) # indeterminate mode
self.progress_bar.setTextVisible(False)
layout.addWidget(self.progress_bar)
self.setLayout(layout)
def set_message(self, message: str):
self.message_label.setText(message)
def set_detail(self, detail: str):
self.message_label.setText(f"{self.message_label.text()}\n{detail}")
def close(self):
self.accept()
class ErrorLogViewer:
"""
错误日志查看器PyQt6 版本)
显示详细的错误和日志信息
"""
def __init__(
self,
parent,
title: str = "错误日志",
errors: Optional[List[Dict[str, Any]]] = None
):
"""
初始化错误日志查看器
Args:
parent: 父窗口
title: 窗口标题
errors: 错误列表
"""
self.parent = parent
self.errors = errors or []
if HAS_TKINTER:
# 使用 tkinter 实现
import tkinter as tk
from tkinter import ttk
super(tk.Toplevel, self).__init__(parent)
self.title(title)
self.geometry("800x600")
self._create_tk_ui()
else:
# 使用 PyQt6 实现
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QComboBox, QTextEdit, QScrollBar, QPushButton
)
from PyQt6.QtCore import Qt
super(QDialog, self).__init__(parent)
self.setWindowTitle(title)
self.resize(800, 600)
layout = QVBoxLayout()
# 工具栏
toolbar_layout = QHBoxLayout()
toolbar_layout.addWidget(QLabel("日志级别:"))
self.level_combo = QComboBox()
self.level_combo.addItems(["ALL", "ERROR", "WARNING", "INFO", "DEBUG"])
self.level_combo.setCurrentText("ERROR")
toolbar_layout.addWidget(self.level_combo)
from PyQt6.QtWidgets import QPushButton
clear_btn = QPushButton("清空")
export_btn = QPushButton("导出")
close_btn = QPushButton("关闭")
toolbar_layout.addWidget(clear_btn)
toolbar_layout.addWidget(export_btn)
toolbar_layout.addWidget(close_btn)
layout.addLayout(toolbar_layout)
# 文本区域
self.text_widget = QTextEdit()
self.text_widget.setReadOnly(True)
self.text_widget.setFont(QtGui.QFont("Consolas", 9))
layout.addWidget(self.text_widget)
self.setLayout(layout)
# 连接信号
clear_btn.clicked.connect(self._on_clear)
export_btn.clicked.connect(self._on_export)
close_btn.clicked.connect(self.accept)
self.level_combo.currentTextChanged.connect(self._load_errors)
self._load_errors()
def _create_tk_ui(self):
"""创建 tkinter UI"""
from tkinter import ttk
import tkinter as tk
# 工具栏
toolbar = ttk.Frame(self)
toolbar.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
ttk.Label(toolbar, text="日志级别:").pack(side=tk.LEFT, padx=5)
self.level_var = tk.StringVar(value="ERROR")
level_combo = ttk.Combobox(
toolbar,
textvariable=self.level_var,
values=["ALL", "ERROR", "WARNING", "INFO", "DEBUG"],
width=10,
state=tk.READONLY
)
level_combo.pack(side=tk.LEFT, padx=5)
level_combo.bind("<<ComboboxSelected>>", self._on_filter_change)
ttk.Button(toolbar, text="清空", command=self._on_clear).pack(side=tk.LEFT, padx=5)
ttk.Button(toolbar, text="导出", command=self._on_export).pack(side=tk.LEFT, padx=5)
ttk.Button(toolbar, text="关闭", command=self.destroy).pack(side=tk.RIGHT, padx=5)
# 主内容区域
content_frame = ttk.Frame(self)
content_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5)
# 创建文本控件
self.text_widget = tk.Text(
content_frame,
wrap=tk.WORD,
font=("Consolas", 9)
)
self.text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# 滚动条
scrollbar = ttk.Scrollbar(content_frame, orient=tk.VERTICAL, command=self.text_widget.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.text_widget.config(yscrollcommand=scrollbar.set)
def _on_filter_change(self, event=None):
"""过滤器改变"""
self._load_errors()
def _on_clear(self):
"""清空日志"""
self.errors.clear()
if HAS_TKINTER and hasattr(self.text_widget, 'delete'):
self.text_widget.delete("1.0", tk.END)
else:
self.text_widget.clear()
self.status_label.config(text="已清空")
def _on_export(self):
"""导出日志"""
if HAS_TKINTER:
from tkinter import filedialog
filename = filedialog.asksaveasfilename(
parent=self,
title="导出日志",
defaultextension=".txt",
filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")]
)
else:
from PyQt6.QtWidgets import QFileDialog
filename, _ = QFileDialog.getSaveFileName(
self,
"导出日志",
"",
"文本文件 (*.txt);;所有文件 (*.*)"
)
if filename:
try:
content = self.text_widget.toPlainText() if not HAS_TKINTER else self.text_widget.get("1.0", tk.END)
with open(filename, 'w', encoding='utf-8') as f:
f.write(content)
if HAS_TKINTER:
from tkinter import messagebox
messagebox.showinfo("导出成功", f"日志已导出到:\n{filename}")
else:
from PyQt6.QtWidgets import QMessageBox
QMessageBox.information(self, "导出成功", f"日志已导出到:\n{filename}")
except Exception as e:
if HAS_TKINTER:
from tkinter import messagebox
messagebox.showerror("导出失败", f"导出失败:\n{e}")
else:
from PyQt6.QtWidgets import QMessageBox
QMessageBox.critical(self, "导出失败", f"导出失败:\n{e}")
def add_error(self, level: str, message: str, timestamp: Optional[datetime] = None):
"""
添加错误
Args:
level: 日志级别
message: 消息
timestamp: 时间戳
"""
if timestamp is None:
timestamp = datetime.now()
self.errors.append({
"level": level,
"message": message,
"timestamp": timestamp
})
self._load_errors()
def _load_errors(self):
"""加载错误"""
level_filter = self.level_combo.currentText() if not HAS_TKINTER else self.level_var.get()
if not HAS_TKINTER:
self.text_widget.clear()
import tkinter as tk
else:
self.text_widget.delete("1.0", tk.END)
count = 0
for error in self.errors:
level = error.get("level", "INFO")
# 过滤
if level_filter != "ALL" and level != level_filter:
continue
count += 1
timestamp = error.get("timestamp", datetime.now())
message = error.get("message", "")
# 格式化时间
if isinstance(timestamp, datetime):
time_str = timestamp.strftime("%H:%M:%S")
else:
time_str = str(timestamp)
# 插入内容
if HAS_TKINTER:
import tkinter as tk
self.text_widget.insert(tk.END, f"[{time_str}] ", "timestamp")
self.text_widget.insert(tk.END, f"[{level}] ", level)
self.text_widget.insert(tk.END, f"{message}\n")
else:
self.text_widget.append(f"[{time_str}] [{level}] {message}")
class ProgressDialog:
"""
进度对话框(选择 Tkinter 或 PyQt6 实现)
显示处理进度和状态
"""
def __init__(
self,
parent,
title: str = "处理中",
message: str = "请稍候...",
cancelable: bool = False,
on_cancel: Optional[Callable] = None
):
"""
初始化进度对话框
Args:
parent: 父窗口
title: 标题
message: 消息
cancelable: 是否可取消
on_cancel: 取消回调
"""
if HAS_TKINTER:
import tkinter as tk
from tkinter import ttk
super(tk.Toplevel, self).__init__(parent)
self.title(title)
self.geometry("400x150")
self.resizable(False, False)
self.transient(parent)
self.grab_set()
self._impl = _TkProgressDialog(self, on_cancel)
self._impl._create_ui(message, cancelable)
else:
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton
super(QDialog, self).__init__(parent)
self.setWindowTitle(title)
self.setFixedSize(400, 150)
self.setModal(True)
layout = QVBoxLayout()
self.message_label = QLabel(message)
layout.addWidget(self.message_label)
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 0) # indeterminate
self.progress_bar.setTextVisible(False)
layout.addWidget(self.progress_bar)
if cancelable:
from PyQt6.QtCore import Qt
cancel_btn = QPushButton("取消")
cancel_btn.clicked.connect(self._on_cancel)
layout.addWidget(cancel_btn)
self.setLayout(layout)
# 居中显示
if parent:
x = parent.x() + (parent.width() - self.width()) // 2
y = parent.y() + (parent.height() - self.height()) // 2
self.move(x, y)
self._impl = self
self.progress_bar = None # 标记不存在
self.on_cancel_callback = on_cancel
self.cancelled = False
# 启动进度条动画
if HAS_TKINTER:
self._impl.progress_bar.start(10)
else:
from PyQt6.QtCore import QPropertyAnimation
# PyQt6 不需要手动启动动画
def set_message(self, message: str):
"""
设置消息
Args:
message: 消息内容
"""
if HAS_TKINTER:
self._impl.set_message(message)
else:
self.message_label.setText(message)
def set_detail(self, detail: str):
"""
设置详细信息
Args:
detail: 详细信息
"""
if HAS_TKINTER:
self._impl.set_detail(detail)
else:
self.message_label.setText(f"{self.message_label.text()}\n{detail}")
def set_progress(self, value: float, maximum: float = 100):
"""
设置进度值
Args:
value: 当前进度值
maximum: 最大值
"""
if HAS_TKINTER:
self._impl.set_progress(value, maximum)
else:
if self.progress_bar:
self.progress_bar.setRange(0, int(maximum))
self.progress_bar.setValue(int(value))
else:
from PyQt6.QtWidgets import QProgressBar
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, int(maximum))
self.progress_bar.setValue(int(value))
def _on_cancel(self):
"""取消按钮点击"""
self.cancelled = True
if self.on_cancel_callback:
self.on_cancel_callback()
self.close()
def is_cancelled(self) -> bool:
"""
检查是否已取消
Returns:
是否已取消
"""
return self.cancelled
def close(self):
"""关闭对话框"""
self.accept()
class _TkProgressDialog:
"""Tkinter 进度对话框实现"""
def __init__(self, on_cancel):
self.on_cancel_callback = on_cancel
self.cancelled = False
self.progress_bar = None
def _create_ui(self, message: str, cancelable: bool):
"""创建 UI"""
import tkinter as tk
from tkinter import ttk
# 主容器
main_frame = ttk.Frame(self, padding=20)
main_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
# 消息标签
self.message_label = ttk.Label(main_frame, text=message, font=("Arial", 10))
self.message_label.pack(side=tk.TOP, pady=(0, 20))
# 进度条
self.progress_bar = ttk.Progressbar(
main_frame,
mode='indeterminate',
length=350
)
self.progress_bar.pack(side=tk.TOP, pady=(0, 10))
# 启动进度条动画
self.progress_bar.start(10)
# 详细信息标签
self.detail_label = ttk.Label(main_frame, text="", font=("Arial", 9))
self.detail_label.pack(side=tk.TOP, pady=(0, 20))
# 取消按钮
if cancelable:
cancel_btn = ttk.Button(main_frame, text="取消", command=self._on_cancel)
cancel_btn.pack(side=tk.TOP)
def set_message(self, message: str):
self.message_label.config(text=message)
def set_detail(self, detail: str):
self.detail_label.config(text=detail)
def set_progress(self, value: float, maximum: float = 100):
self.progress_bar.config(mode='determinate')
self.progress_bar.config(maximum=maximum)
self.progress_bar.config(value=value)
def _on_cancel(self):
"""取消按钮点击"""
self.cancelled = True
if self.on_cancel_callback:
self.on_cancel_callback()
# 关闭对话框
import tkinter as tk
self.destroy() # tkinter 的 Toplevel 有 destroy 方法
# 便捷函数
def show_info(title: str, message: str, details: Optional[str] = None, parent=None):
"""显示信息对话框"""
handler = MessageHandler(parent)
handler.show_info(title, message, details)
def show_warning(title: str, message: str, details: Optional[str] = None, parent=None):
"""显示警告对话框"""
handler = MessageHandler(parent)
handler.show_warning(title, message, details)
def show_error(title: str, message: str, details: Optional[str] = None, exception: Optional[Exception] = None, parent=None):
"""显示错误对话框"""
handler = MessageHandler(parent)
handler.show_error(title, message, details, exception)
def ask_yes_no(title: str, message: str, parent=None, default: bool = True) -> bool:
"""询问是/否"""
handler = MessageHandler(parent)
return handler.ask_yes_no(title, message, default)
def ask_ok_cancel(title: str, message: str, parent=None, default: bool = True) -> bool:
"""询问确定/取消"""
handler = MessageHandler(parent)
return handler.ask_ok_cancel(title, message, default)

View File

@@ -1,290 +0,0 @@
"""
记录卡片组件
用于在浏览视图中展示单条记录的卡片,包含:
- 缩略图预览
- 分类标签
- OCR 文本预览
- 时间戳
- 点击打开详情
"""
from datetime import datetime
from pathlib import Path
from typing import Optional
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame,
QPushButton, QGraphicsDropShadowEffect
)
from PyQt6.QtCore import Qt, pyqtSignal, QSize
from PyQt6.QtGui import QPixmap, QImage, QPainter, QPalette, QColor, QFont
class RecordCard(QFrame):
"""
记录卡片组件
显示单条记录的摘要信息,点击可查看详情
"""
# 定义信号点击卡片时发出传递记录ID
clicked = pyqtSignal(int)
# 分类颜色映射
CATEGORY_COLORS = {
"TODO": "#5DADE2", # 蓝色
"NOTE": "#58D68D", # 绿色
"IDEA": "#F5B041", # 橙色
"REF": "#AF7AC5", # 紫色
"FUNNY": "#EC7063", # 红色
"TEXT": "#95A5A6", # 灰色
}
# 分类名称映射
CATEGORY_NAMES = {
"TODO": "待办",
"NOTE": "笔记",
"IDEA": "灵感",
"REF": "参考",
"FUNNY": "趣味",
"TEXT": "文本",
}
def __init__(self, record_id: int, image_path: str, ocr_text: str,
category: str, created_at: Optional[datetime] = None,
parent: Optional[QWidget] = None):
"""
初始化记录卡片
Args:
record_id: 记录ID
image_path: 图片路径
ocr_text: OCR识别的文本
category: 分类
created_at: 创建时间
parent: 父组件
"""
super().__init__(parent)
self.record_id = record_id
self.image_path = image_path
self.ocr_text = ocr_text or ""
self.category = category
self.created_at = created_at
# 设置卡片样式
self.setup_ui()
self.set_style()
def setup_ui(self):
"""设置UI布局"""
self.setFrameStyle(QFrame.Shape.Box)
self.setCursor(Qt.CursorShape.PointingHandCursor)
# 主布局
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(10)
# 1. 缩略图
self.thumbnail_label = QLabel()
self.thumbnail_label.setMinimumHeight(150)
self.thumbnail_label.setMaximumHeight(150)
self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.thumbnail_label.setStyleSheet("""
QLabel {
background-color: #F5F5F5;
border-radius: 8px;
border: 1px solid #E0E0E0;
}
""")
self.load_thumbnail()
layout.addWidget(self.thumbnail_label)
# 2. 分类标签和时间
header_layout = QHBoxLayout()
header_layout.setSpacing(8)
self.category_label = QLabel()
self.category_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.category_label.setStyleSheet("""
QLabel {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
color: white;
}
""")
self.update_category_label()
header_layout.addWidget(self.category_label)
header_layout.addStretch()
self.time_label = QLabel()
self.time_label.setStyleSheet("""
QLabel {
color: #999999;
font-size: 11px;
}
""")
self.update_time_label()
header_layout.addWidget(self.time_label)
layout.addLayout(header_layout)
# 3. OCR文本预览
self.preview_label = QLabel()
self.preview_label.setWordWrap(True)
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
self.preview_label.setMinimumHeight(60)
self.preview_label.setMaximumHeight(80)
self.preview_label.setStyleSheet("""
QLabel {
color: #333333;
font-size: 13px;
line-height: 1.4;
}
""")
self.update_preview_text()
layout.addWidget(self.preview_label)
# 设置卡片固定宽度
self.setFixedWidth(280)
self.setMinimumHeight(320)
def set_style(self):
"""设置卡片整体样式"""
self.setStyleSheet("""
RecordCard {
background-color: white;
border-radius: 12px;
border: 1px solid #E8E8E8;
}
RecordCard:hover {
background-color: #FAFAFA;
border: 1px solid #4A90E2;
}
""")
# 添加阴影效果
shadow = QGraphicsDropShadowEffect()
shadow.setBlurRadius(10)
shadow.setOffset(0, 2)
shadow.setColor(QColor(0, 0, 0, 30))
self.setGraphicsEffect(shadow)
def load_thumbnail(self):
"""加载缩略图"""
try:
image_path = Path(self.image_path)
if image_path.exists():
# 加载图片
pixmap = QPixmap(str(image_path))
# 缩放到合适大小(保持比例)
scaled_pixmap = pixmap.scaled(
260, 140,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
self.thumbnail_label.setPixmap(scaled_pixmap)
else:
# 图片不存在,显示占位符
self.thumbnail_label.setText("图片未找到")
except Exception as e:
# 加载失败,显示占位符
self.thumbnail_label.setText("加载失败")
def update_category_label(self):
"""更新分类标签"""
category_name = self.CATEGORY_NAMES.get(self.category, self.category)
self.category_label.setText(category_name)
# 设置分类颜色
color = self.CATEGORY_COLORS.get(self.category, "#95A5A6")
self.category_label.setStyleSheet(f"""
QLabel {{
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
color: white;
background-color: {color};
}}
""")
def update_time_label(self):
"""更新时间标签"""
if self.created_at:
# 格式化时间
now = datetime.now()
diff = now - self.created_at
if diff.days > 7:
# 超过一周显示完整日期
time_str = self.created_at.strftime("%Y-%m-%d")
elif diff.days > 0:
# 几天前
time_str = f"{diff.days}天前"
elif diff.seconds >= 3600:
# 几小时前
hours = diff.seconds // 3600
time_str = f"{hours}小时前"
elif diff.seconds >= 60:
# 几分钟前
minutes = diff.seconds // 60
time_str = f"{minutes}分钟前"
else:
time_str = "刚刚"
else:
time_str = ""
self.time_label.setText(time_str)
def update_preview_text(self):
"""更新预览文本"""
if self.ocr_text:
# 截取前100个字符作为预览
preview = self.ocr_text[:100]
if len(self.ocr_text) > 100:
preview += "..."
self.preview_label.setText(preview)
else:
self.preview_label.setText("无文本内容")
def mousePressEvent(self, event):
"""鼠标点击事件"""
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self.record_id)
super().mousePressEvent(event)
def update_data(self, image_path: Optional[str] = None,
ocr_text: Optional[str] = None,
category: Optional[str] = None,
created_at: Optional[datetime] = None):
"""
更新卡片数据
Args:
image_path: 新的图片路径
ocr_text: 新的OCR文本
category: 新的分类
created_at: 新的创建时间
"""
if image_path is not None:
self.image_path = image_path
self.load_thumbnail()
if ocr_text is not None:
self.ocr_text = ocr_text
self.update_preview_text()
if category is not None:
self.category = category
self.update_category_label()
if created_at is not None:
self.created_at = created_at
self.update_time_label()

View File

@@ -1,442 +0,0 @@
"""
记录详情对话框
显示单条记录的完整信息:
- 完整图片预览
- 完整OCR文本
- AI分析结果
- 分类和标签
- 支持编辑和删除操作
"""
from typing import Optional, List
from datetime import datetime
from pathlib import Path
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QTextEdit, QScrollArea, QComboBox, QFrame, QSizePolicy,
QMessageBox, QWidget
)
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QPixmap, QFont, QTextDocument
from src.models.database import RecordCategory
class RecordDetailDialog(QDialog):
"""
记录详情对话框
显示记录的完整信息支持查看图片、OCR文本和AI结果
"""
def __init__(self, record_id: int, image_path: str, ocr_text: str,
category: str, ai_result: Optional[str] = None,
tags: Optional[List[str]] = None, notes: Optional[str] = None,
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
parent: Optional['QWidget'] = None):
"""
初始化记录详情对话框
Args:
record_id: 记录ID
image_path: 图片路径
ocr_text: OCR文本
category: 分类
ai_result: AI分析结果
tags: 标签列表
notes: 备注
created_at: 创建时间
updated_at: 更新时间
parent: 父组件
"""
super().__init__(parent)
self.record_id = record_id
self.image_path = image_path
self.ocr_text = ocr_text or ""
self.category = category
self.ai_result = ai_result or ""
self.tags = tags or []
self.notes = notes or ""
self.created_at = created_at
self.updated_at = updated_at
self.modified = False
self.setup_ui()
self.load_data()
def setup_ui(self):
"""设置UI布局"""
self.setWindowTitle("记录详情")
self.setMinimumSize(900, 700)
self.resize(1000, 800)
# 主布局
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(15)
# 顶部工具栏
toolbar_layout = QHBoxLayout()
toolbar_layout.setSpacing(10)
# 分类选择
category_label = QLabel("分类:")
category_label.setStyleSheet("font-size: 14px; font-weight: bold;")
toolbar_layout.addWidget(category_label)
self.category_combo = QComboBox()
self.category_combo.addItems(RecordCategory.all())
self.category_combo.currentTextChanged.connect(self.on_category_changed)
toolbar_layout.addWidget(self.category_combo)
toolbar_layout.addStretch()
# 删除按钮
self.delete_btn = QPushButton("删除记录")
self.delete_btn.setStyleSheet("""
QPushButton {
background-color: #EC7063;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
}
QPushButton:hover {
background-color: #E74C3C;
}
""")
self.delete_btn.clicked.connect(self.delete_record)
toolbar_layout.addWidget(self.delete_btn)
# 关闭按钮
self.close_btn = QPushButton("关闭")
self.close_btn.setStyleSheet("""
QPushButton {
background-color: #95A5A6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
}
QPushButton:hover {
background-color: #7F8C8D;
}
""")
self.close_btn.clicked.connect(self.close)
toolbar_layout.addWidget(self.close_btn)
main_layout.addLayout(toolbar_layout)
# 创建滚动区域
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
# 滚动内容
scroll_content = QWidget()
scroll_layout = QVBoxLayout(scroll_content)
scroll_layout.setSpacing(20)
scroll_layout.setContentsMargins(10, 10, 10, 10)
# 1. 图片预览
image_section = self.create_image_section()
scroll_layout.addWidget(image_section)
# 2. OCR文本
ocr_section = self.create_ocr_section()
scroll_layout.addWidget(ocr_section)
# 3. AI分析结果
ai_section = self.create_ai_section()
scroll_layout.addWidget(ai_section)
# 4. 备注
notes_section = self.create_notes_section()
scroll_layout.addWidget(notes_section)
# 5. 时间信息
time_section = self.create_time_section()
scroll_layout.addWidget(time_section)
scroll_layout.addStretch()
scroll_area.setWidget(scroll_content)
main_layout.addWidget(scroll_area)
def create_image_section(self) -> QFrame:
"""创建图片预览区域"""
frame = QFrame()
frame.setFrameStyle(QFrame.Shape.StyledPanel)
frame.setStyleSheet("""
QFrame {
background-color: white;
border-radius: 8px;
border: 1px solid #E0E0E0;
padding: 15px;
}
""")
layout = QVBoxLayout(frame)
# 标题
title = QLabel("原始图片")
title.setStyleSheet("font-size: 16px; font-weight: bold; color: #333;")
layout.addWidget(title)
# 图片预览
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setMinimumHeight(300)
self.image_label.setStyleSheet("background-color: #F5F5F5; border-radius: 6px;")
layout.addWidget(self.image_label)
return frame
def create_ocr_section(self) -> QFrame:
"""创建OCR文本区域"""
frame = QFrame()
frame.setFrameStyle(QFrame.Shape.StyledPanel)
frame.setStyleSheet("""
QFrame {
background-color: white;
border-radius: 8px;
border: 1px solid #E0E0E0;
padding: 15px;
}
""")
layout = QVBoxLayout(frame)
# 标题
title = QLabel("OCR识别结果")
title.setStyleSheet("font-size: 16px; font-weight: bold; color: #333;")
layout.addWidget(title)
# 文本内容
self.ocr_text_edit = QTextEdit()
self.ocr_text_edit.setReadOnly(True)
self.ocr_text_edit.setMinimumHeight(150)
self.ocr_text_edit.setStyleSheet("""
QTextEdit {
border: 1px solid #E0E0E0;
border-radius: 6px;
padding: 10px;
background-color: #FAFAFA;
font-size: 13px;
line-height: 1.6;
}
""")
layout.addWidget(self.ocr_text_edit)
return frame
def create_ai_section(self) -> QFrame:
"""创建AI分析结果区域"""
frame = QFrame()
frame.setFrameStyle(QFrame.Shape.StyledPanel)
frame.setStyleSheet("""
QFrame {
background-color: white;
border-radius: 8px;
border: 1px solid #E0E0E0;
padding: 15px;
}
""")
layout = QVBoxLayout(frame)
# 标题
title = QLabel("AI分析结果")
title.setStyleSheet("font-size: 16px; font-weight: bold; color: #333;")
layout.addWidget(title)
# 使用 QTextEdit 显示 Markdown
self.ai_text_edit = QTextEdit()
self.ai_text_edit.setReadOnly(True)
self.ai_text_edit.setMinimumHeight(200)
self.ai_text_edit.setStyleSheet("""
QTextEdit {
border: 1px solid #E0E0E0;
border-radius: 6px;
padding: 15px;
background-color: #FAFAFA;
font-size: 14px;
line-height: 1.8;
}
""")
layout.addWidget(self.ai_text_edit)
return frame
def create_notes_section(self) -> QFrame:
"""创建备注区域"""
frame = QFrame()
frame.setFrameStyle(QFrame.Shape.StyledPanel)
frame.setStyleSheet("""
QFrame {
background-color: white;
border-radius: 8px;
border: 1px solid #E0E0E0;
padding: 15px;
}
""")
layout = QVBoxLayout(frame)
# 标题
title = QLabel("备注")
title.setStyleSheet("font-size: 16px; font-weight: bold; color: #333;")
layout.addWidget(title)
# 备注输入
self.notes_edit = QTextEdit()
self.notes_edit.setPlaceholderText("添加备注...")
self.notes_edit.setMinimumHeight(100)
self.notes_edit.setStyleSheet("""
QTextEdit {
border: 1px solid #E0E0E0;
border-radius: 6px;
padding: 10px;
background-color: white;
font-size: 13px;
}
""")
self.notes_edit.textChanged.connect(self.on_content_changed)
layout.addWidget(self.notes_edit)
return frame
def create_time_section(self) -> QFrame:
"""创建时间信息区域"""
frame = QFrame()
frame.setStyleSheet("""
QFrame {
background-color: #F8F9FA;
border-radius: 8px;
padding: 15px;
}
""")
layout = QHBoxLayout(frame)
self.created_at_label = QLabel()
self.created_at_label.setStyleSheet("color: #666; font-size: 12px;")
layout.addWidget(self.created_at_label)
layout.addStretch()
self.updated_at_label = QLabel()
self.updated_at_label.setStyleSheet("color: #666; font-size: 12px;")
layout.addWidget(self.updated_at_label)
return frame
def load_data(self):
"""加载数据到界面"""
# 设置分类
index = self.category_combo.findText(self.category)
if index >= 0:
self.category_combo.setCurrentIndex(index)
# 加载图片
try:
image_path = Path(self.image_path)
if image_path.exists():
pixmap = QPixmap(str(image_path))
# 缩放图片以适应区域
scaled_pixmap = pixmap.scaled(
800, 600,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
self.image_label.setPixmap(scaled_pixmap)
else:
self.image_label.setText("图片文件不存在")
except Exception as e:
self.image_label.setText(f"加载图片失败: {str(e)}")
# 设置OCR文本
self.ocr_text_edit.setPlainText(self.ocr_text)
# 设置AI结果显示纯文本
if self.ai_result:
self.ai_text_edit.setPlainText(self.ai_result)
else:
self.ai_text_edit.setPlainText("无AI分析结果")
# 设置备注
self.notes_edit.setPlainText(self.notes)
# 设置时间信息
if self.created_at:
self.created_at_label.setText(f"创建时间: {self.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
if self.updated_at:
self.updated_at_label.setText(f"更新时间: {self.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
def on_category_changed(self, category: str):
"""分类改变时"""
self.category = category
self.modified = True
def on_content_changed(self):
"""内容改变时"""
self.modified = True
def delete_record(self):
"""删除记录"""
reply = QMessageBox.question(
self,
"确认删除",
"确定要删除这条记录吗?\n\n此操作不可撤销!",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
# 发出删除信号(由父窗口处理)
self.accept()
# 这里可以添加自定义信号通知父窗口删除记录
def get_data(self) -> dict:
"""
获取修改后的数据
Returns:
包含修改后数据的字典
"""
return {
'category': self.category_combo.currentText(),
'notes': self.notes_edit.toPlainText(),
'modified': self.modified
}
def closeEvent(self, event):
"""关闭事件"""
if self.modified:
reply = QMessageBox.question(
self,
"保存更改",
"记录已被修改,是否保存?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Yes
)
if reply == QMessageBox.StandardButton.Yes:
# 保存更改
self.accept()
elif reply == QMessageBox.StandardButton.Cancel:
event.ignore()
return
else:
# 不保存直接关闭
event.accept()
else:
event.accept()

View File

@@ -1,282 +0,0 @@
"""
结果展示组件
用于展示处理结果,包括:
- OCR 文本展示
- AI 处理结果展示(纯文本格式)
- 一键复制功能
- 日志查看
"""
from typing import Optional, Callable
import logging
# 尝试导入 tkinter失败时使用 PyQt6
try:
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox
HAS_TKINTER = True
except ImportError:
HAS_TKINTER = False
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QTextEdit, QComboBox, QProgressBar
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from src.core.processor import ProcessResult, create_markdown_result, copy_to_clipboard
logger = logging.getLogger(__name__)
class ResultWidget(QWidget):
"""
结果展示组件 (PyQt6 版本)
显示处理结果,支持 Markdown 渲染和一键复制
"""
# 信号:内容改变
content_changed = pyqtSignal(str)
def __init__(
self,
parent,
copy_callback: Optional[Callable] = None,
**kwargs
):
"""
初始化结果展示组件
Args:
parent: 父容器
copy_callback: 复制按钮回调函数
**kwargs: 其他参数
"""
super().__init__(parent, **kwargs)
self.copy_callback = copy_callback
self.current_result: Optional[ProcessResult] = None
self.display_mode = "raw" # raw 或 markdown
self._create_ui()
def _create_ui(self):
"""创建 UI"""
layout = QVBoxLayout()
# 顶部工具栏
toolbar_layout = QHBoxLayout()
# 结果类型选择
toolbar_layout.addWidget(QLabel("显示:"))
from PyQt6.QtWidgets import QRadioButton, QButtonGroup
self.mode_group = QButtonGroup()
raw_btn = QRadioButton("原始文本")
raw_btn.setChecked(True)
raw_btn.clicked.connect(lambda: self._set_mode("raw"))
self.mode_group.addButton(raw_btn)
toolbar_layout.addWidget(raw_btn)
md_btn = QRadioButton("Markdown")
md_btn.clicked.connect(lambda: self._set_mode("markdown"))
self.mode_group.addButton(md_btn)
toolbar_layout.addWidget(md_btn)
toolbar_layout.addStretch()
# 右侧按钮
self.copy_button = QPushButton("复制")
self.copy_button.clicked.connect(self._on_copy)
toolbar_layout.addWidget(self.copy_button)
self.clear_button = QPushButton("清空")
self.clear_button.clicked.connect(self._on_clear)
toolbar_layout.addWidget(self.clear_button)
layout.addLayout(toolbar_layout)
# 主内容区域
self.text_widget = QTextEdit()
self.text_widget.setReadOnly(True)
self.text_widget.setFont(QFont("Consolas", 10))
layout.addWidget(self.text_widget)
self.setLayout(layout)
def _set_mode(self, mode: str):
"""设置显示模式"""
self.display_mode = mode
self._update_result_content()
def _on_copy(self):
"""复制按钮点击"""
content = self.text_widget.toPlainText().strip()
if not content:
if HAS_TKINTER:
from tkinter import messagebox
messagebox.showinfo("提示", "没有可复制的内容")
else:
from PyQt6.QtWidgets import QMessageBox
QMessageBox.information(self, "提示", "没有可复制的内容")
return
success = copy_to_clipboard(content)
if success:
self._update_status("已复制到剪贴板")
if self.copy_callback:
self.copy_callback(content)
else:
self._update_status("复制失败,请检查是否安装了 pyperclip")
def _on_clear(self):
"""清空按钮点击"""
self.text_widget.clear()
self.current_result = None
self._update_status("已清空")
def _update_result_content(self):
"""更新结果内容"""
if not self.current_result:
self.text_widget.clear()
return
mode = self.display_mode
if mode == "markdown":
content = self._get_markdown_content()
else:
content = self._get_raw_content()
self.text_widget.setPlainText(content)
def _get_markdown_content(self) -> str:
"""获取 Markdown 格式内容"""
if not self.current_result:
return ""
ai_result = self.current_result.ai_result
ocr_text = self.current_result.ocr_result.full_text if self.current_result.ocr_result else ""
return create_markdown_result(ai_result, ocr_text)
def _get_raw_content(self) -> str:
"""获取原始文本内容"""
if not self.current_result:
return ""
parts = []
# OCR 文本
if self.current_result.ocr_result:
parts.append("## OCR 识别结果\n")
parts.append(self.current_result.ocr_result.full_text)
parts.append(f"\n\n置信度: {self.current_result.ocr_result.total_confidence:.2%}\n")
# AI 结果
if self.current_result.ai_result:
parts.append("\n## AI 处理结果\n")
parts.append(f"分类: {self.current_result.ai_result.category.value}\n")
parts.append(f"置信度: {self.current_result.ai_result.confidence:.2%}\n")
parts.append(f"标题: {self.current_result.ai_result.title}\n")
parts.append(f"标签: {', '.join(self.current_result.ai_result.tags)}\n")
parts.append(f"\n内容:\n{self.current_result.ai_result.content}\n")
# 处理信息
parts.append("\n## 处理信息\n")
parts.append(f"成功: {'' if self.current_result.success else ''}\n")
parts.append(f"耗时: {self.current_result.process_time:.2f}\n")
parts.append(f"已完成的步骤: {', '.join(self.current_result.steps_completed)}\n")
if self.current_result.warnings:
parts.append(f"\n警告:\n")
for warning in self.current_result.warnings:
parts.append(f" - {warning}\n")
return "\n".join(parts)
def _update_status(self, message: str):
"""更新状态"""
# 这里可以发出信号让父窗口更新状态
pass
def set_result(self, result: ProcessResult):
"""
设置处理结果并显示
Args:
result: 处理结果
"""
self.current_result = result
self._update_result_content()
# 更新状态
if result.success:
status = f"处理成功 | 耗时 {result.process_time:.2f}"
else:
status = f"处理失败: {result.error_message or '未知错误'}"
self._update_status(status)
def append_log(self, level: str, message: str):
"""添加日志"""
# 简化版本:直接输出到控制台
from datetime import datetime
timestamp = datetime.now().strftime("%H:%M:%S")
print(f"[{timestamp}] [{level}] {message}")
class QuickResultDialog:
"""
快速结果显示对话框 (PyQt6 版本)
用于快速显示处理结果,不集成到主界面
"""
def __init__(
self,
parent,
result: ProcessResult,
on_close: Optional[Callable] = None
):
"""
初始化对话框
Args:
parent: 父窗口
result: 处理结果
on_close: 关闭回调
"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QHBoxLayout, QLabel
super(QDialog, self).__init__(parent)
self.result = result
self.on_close = on_close
self.setWindowTitle("处理结果")
self.resize(600, 400)
layout = QVBoxLayout()
# 显示结果
result_widget = ResultWidget(self)
result_widget.set_result(result)
layout.addWidget(result_widget)
# 底部按钮
button_layout = QHBoxLayout()
close_btn = QPushButton("关闭")
close_btn.clicked.connect(self._on_close)
button_layout.addWidget(close_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
self.setLayout(layout)
def _on_close(self):
"""关闭对话框"""
if self.on_close:
self.on_close()
self.accept()

View File

@@ -1,368 +0,0 @@
"""
截图窗口组件
实现全屏截图功能,包括:
- 全屏透明覆盖窗口
- 区域选择
- 截图预览
- 保存和取消操作
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QApplication, QMessageBox
)
from PyQt6.QtCore import Qt, QRect, QPoint, QSize, pyqtSignal
from PyQt6.QtGui import (
QPixmap, QPainter, QPen, QColor, QScreen,
QCursor, QGuiApplication
)
from datetime import datetime
from pathlib import Path
import tempfile
class ScreenshotOverlay(QWidget):
"""
全屏截图覆盖窗口
提供全屏透明的截图区域选择界面
"""
# 信号:截图完成时发出,传递图片和截图区域
screenshot_taken = pyqtSignal(QPixmap, QRect)
# 信号:取消截图
screenshot_cancelled = pyqtSignal()
def __init__(self, parent=None):
"""初始化截图覆盖窗口"""
super().__init__(parent)
# 设置窗口标志:无边框、置顶、全屏
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool
)
# 设置半透明背景
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
# 状态变量
self.is_capturing = False
self.is_dragging = False
self.start_pos = QPoint()
self.end_pos = QPoint()
self.current_rect = QRect()
# 获取屏幕截图
self.screen_pixmap = self._capture_screen()
# 初始化UI
self._init_ui()
def _init_ui(self):
"""初始化UI"""
# 设置全屏
screen = QApplication.primaryScreen()
if screen:
screen_geometry = screen.availableGeometry()
self.setGeometry(screen_geometry)
# 创建工具栏
self._create_toolbar()
def _create_toolbar(self):
"""创建底部工具栏"""
self.toolbar = QWidget(self)
self.toolbar.setObjectName("screenshotToolbar")
toolbar_layout = QHBoxLayout(self.toolbar)
toolbar_layout.setContentsMargins(16, 8, 16, 8)
toolbar_layout.setSpacing(12)
# 完成按钮
self.finish_btn = QPushButton("✓ 完成")
self.finish_btn.setObjectName("screenshotButton")
self.finish_btn.setMinimumSize(80, 36)
self.finish_btn.clicked.connect(self._on_finish)
self.finish_btn.setEnabled(False)
toolbar_layout.addWidget(self.finish_btn)
# 取消按钮
self.cancel_btn = QPushButton("✕ 取消")
self.cancel_btn.setObjectName("screenshotButton")
self.cancel_btn.setMinimumSize(80, 36)
self.cancel_btn.clicked.connect(self._on_cancel)
toolbar_layout.addWidget(self.cancel_btn)
# 设置工具栏样式
self.toolbar.setStyleSheet("""
QWidget#screenshotToolbar {
background-color: rgba(40, 40, 40, 230);
border-radius: 8px;
}
QPushButton#screenshotButton {
background-color: #4A90E2;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
}
QPushButton#screenshotButton:hover {
background-color: #357ABD;
}
QPushButton#screenshotButton:pressed {
background-color: #2A639D;
}
QPushButton#screenshotButton:disabled {
background-color: #CCCCCC;
color: #666666;
}
""")
# 初始隐藏工具栏
self.toolbar.hide()
def _capture_screen(self) -> QPixmap:
"""
捕获屏幕截图
Returns:
屏幕截图的 QPixmap
"""
screen = QApplication.primaryScreen()
if screen:
return screen.grabWindow(0) # 0 = 整个屏幕
return QPixmap()
def paintEvent(self, event):
"""绘制事件"""
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# 1. 绘制半透明黑色背景
painter.fillRect(self.rect(), QColor(0, 0, 0, 100))
# 2. 如果有选择区域,绘制选区
if self.is_capturing and not self.current_rect.isEmpty():
# 清除选区背景(显示原始屏幕内容)
painter.drawPixmap(
self.current_rect.topLeft(),
self.screen_pixmap,
self.current_rect
)
# 绘制选区边框
pen = QPen(QColor(74, 144, 226), 2)
painter.setPen(pen)
painter.drawRect(self.current_rect)
# 绘制尺寸信息
size_text = f"{self.current_rect.width()} x {self.current_rect.height()}"
painter.setPen(QColor(255, 255, 255))
painter.drawText(
self.current_rect.x(),
self.current_rect.y() - 10,
size_text
)
# 更新工具栏位置(在选区下方中央)
toolbar_width = 200
toolbar_height = 52
x = self.current_rect.center().x() - toolbar_width // 2
y = self.current_rect.bottom() + 10
# 确保工具栏在屏幕内
if y + toolbar_height > self.height():
y = self.current_rect.top() - toolbar_height - 10
self.toolbar.setGeometry(x, y, toolbar_width, toolbar_height)
self.toolbar.show()
else:
self.toolbar.hide()
def mousePressEvent(self, event):
"""鼠标按下事件"""
if event.button() == Qt.MouseButton.LeftButton:
self.is_dragging = True
self.start_pos = event.pos()
self.end_pos = event.pos()
self.is_capturing = True
self.finish_btn.setEnabled(False)
self.update()
def mouseMoveEvent(self, event):
"""鼠标移动事件"""
if self.is_dragging:
self.end_pos = event.pos()
# 计算选择区域
x = min(self.start_pos.x(), self.end_pos.x())
y = min(self.start_pos.y(), self.end_pos.y())
width = abs(self.end_pos.x() - self.start_pos.x())
height = abs(self.end_pos.y() - self.start_pos.y())
self.current_rect = QRect(x, y, width, height)
# 只有当区域足够大时才启用完成按钮
self.finish_btn.setEnabled(width > 10 and height > 10)
self.update()
def mouseReleaseEvent(self, event):
"""鼠标释放事件"""
if event.button() == Qt.MouseButton.LeftButton:
self.is_dragging = False
def keyPressEvent(self, event):
"""键盘事件"""
# ESC 键取消截图
if event.key() == Qt.Key.Key_Escape:
self._on_cancel()
# Enter 键完成截图
elif event.key() == Qt.Key.Key_Return:
if self.finish_btn.isEnabled():
self._on_finish()
def _on_finish(self):
"""完成截图"""
if not self.current_rect.isEmpty():
# 从屏幕截图中裁剪选区
screenshot = self.screen_pixmap.copy(self.current_rect)
self.screenshot_taken.emit(screenshot, self.current_rect)
self.close()
def _on_cancel(self):
"""取消截图"""
self.screenshot_cancelled.emit()
self.close()
def show_screenshot(self):
"""显示截图窗口"""
self.showFullScreen()
# 设置鼠标为十字准星
self.setCursor(Qt.CursorShape.CrossCursor)
class ScreenshotWidget(QWidget):
"""
截图管理组件
提供完整的截图功能,包括触发、处理和保存
"""
# 信号:截图完成,传递图片路径
screenshot_saved = pyqtSignal(str)
def __init__(self, parent=None):
"""初始化截图组件"""
super().__init__(parent)
# 截图覆盖窗口
self.overlay = None
# 临时保存目录
self.temp_dir = Path(tempfile.gettempdir()) / "cutthenthink" / "screenshots"
self.temp_dir.mkdir(parents=True, exist_ok=True)
def take_screenshot(self):
"""触发截图"""
# 创建并显示截图覆盖窗口
self.overlay = ScreenshotOverlay()
self.overlay.screenshot_taken.connect(self._on_screenshot_taken)
self.overlay.screenshot_cancelled.connect(self._on_screenshot_cancelled)
self.overlay.show_screenshot()
def _on_screenshot_taken(self, pixmap: QPixmap, rect: QRect):
"""
截图完成的回调
Args:
pixmap: 截图图片
rect: 截图区域
"""
# 保存截图到临时目录
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"screenshot_{timestamp}.png"
filepath = self.temp_dir / filename
if pixmap.save(str(filepath)):
self.screenshot_saved.emit(str(filepath))
else:
QMessageBox.warning(
self,
"保存失败",
f"无法保存截图到:{filepath}"
)
def _on_screenshot_cancelled(self):
"""截图取消的回调"""
# 可选:显示提示或执行其他操作
pass
def take_and_save_screenshot(self, save_path: str = None) -> str:
"""
截图并保存到指定路径(同步版本,阻塞等待)
Args:
save_path: 保存路径,为 None 时使用默认路径
Returns:
保存的文件路径,失败返回 None
"""
# 这个版本需要使用事件循环同步等待
# 由于 PyQt 的事件机制,建议使用信号方式
# 这里提供一个简单的实现供测试使用
import asyncio
future = asyncio.Future()
def on_saved(path):
future.set_result(path)
self.screenshot_saved.connect(on_saved)
self.take_screenshot()
# 注意:实际使用时建议在异步上下文中调用
return None
class QuickScreenshotHelper:
"""
快速截图助手类
用于全局快捷键触发截图
"""
_instance = None
_screenshot_widget = None
@classmethod
def get_instance(cls):
"""获取单例实例"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def trigger_screenshot(cls):
"""触发截图(可被全局快捷键调用)"""
instance = cls.get_instance()
if instance._screenshot_widget is None:
instance._screenshot_widget = ScreenshotWidget()
instance._screenshot_widget.take_screenshot()
@classmethod
def set_screenshot_widget(cls, widget: ScreenshotWidget):
"""设置截图组件实例"""
instance = cls.get_instance()
instance._screenshot_widget = widget
# 便捷函数
def take_screenshot():
"""触发截图的便捷函数"""
QuickScreenshotHelper.trigger_screenshot()