diff --git a/README.md b/README.md index f51e627..2b64933 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,113 @@ # CutThenThink -智能截图OCR与AI分析工具 +**极简截图上传工具** ## 项目简介 -CutThenThink 是一款基于 PyQt6 的桌面应用程序,集成了OCR文字识别和AI智能分析功能。用户可以通过截图、选择区域,然后使用OCR提取文字,并利用多种AI模型进行智能分析和处理。 +CutThenThink 是一个轻量级的桌面截图工具,专注于: +- 📷 快速截图(全屏/区域) +- ☁️ 云端上传(支持多种服务) +- 📁 历史记录管理 +- 🔍 可选 OCR 文字识别 -## 主要功能 +## 特点 -- **智能截图**: 支持多种方式截图(矩形选择、窗口选择、全屏等) -- **OCR识别**: 基于PaddleOCR的高精度文字识别 -- **AI分析**: 支持多种AI模型(OpenAI GPT、Anthropic Claude等) -- **内容编辑**: 内置编辑器,支持图片标注和文字编辑 -- **历史记录**: 本地数据库保存所有截图和分析记录 -- **快捷操作**: 全局快捷键支持,快速截图和分析 - -## 技术栈 - -- **GUI框架**: PyQt6 6.6.1 -- **数据库**: SQLAlchemy 2.0.25 -- **OCR引擎**: PaddleOCR 2.7.0.3 -- **AI模型**: OpenAI API、Anthropic API -- **图像处理**: Pillow 10.0.0 +- **轻量级**:核心依赖仅 ~50MB +- **可选 OCR**:RapidOCR 插件,按需安装 +- **无重型依赖**:移除了 torch、transformers、paddleocr +- **简单配置**:YAML 单文件配置 +- **跨平台**:支持 Windows、macOS、Linux ## 安装 -### 环境要求 +### 基础安装 -- Python 3.8+ -- 操作系统: Windows / macOS / Linux - -### 安装步骤 - -1. 克隆项目 -```bash -git clone -cd CutThenThink -``` - -2. 创建虚拟环境 -```bash -python -m venv venv -source venv/bin/activate # Linux/macOS -# 或 -venv\Scripts\activate # Windows -``` - -3. 安装依赖 ```bash pip install -r requirements.txt +python src/main.py ``` -4. 配置AI服务 +### 可选:安装 OCR 支持 -创建配置文件 `config.yaml`: -```yaml -ai: - provider: "openai" # 或 "anthropic" - openai: - api_key: "your-openai-api-key" - model: "gpt-4" - anthropic: - api_key: "your-anthropic-api-key" - model: "claude-3-sonnet-20240229" +```bash +pip install -r requirements-ocr.txt ``` ## 使用方法 -启动应用: -```bash -python src/main.py -``` +### 快捷键 -默认快捷键: -- `Ctrl+Shift+A`: 截图并分析 -- `Ctrl+Shift+S`: 仅截图 -- `Ctrl+Shift+H`: 打开历史记录 -- `Esc`: 取消截图 +| 快捷键 | 功能 | +|--------|------| +| `Ctrl+Shift+A` | 全屏截图 | +| `Ctrl+Shift+R` | 区域截图 | +| `Ctrl+Shift+U` | 上传最后截图 | +| `Esc` | 退出 | + +### 配置 + +配置文件位于 `~/.cutthenthink/config.yaml`: + +```yaml +upload: + provider: custom # custom, telegraph, imgur + endpoint: https://... + api_key: your-key + auto_copy: true + +screenshot: + format: png # png, jpg, webp + save_path: ~/Pictures/Screenshots + +hotkeys: + capture: Ctrl+Shift+A + region: Ctrl+Shift+R + upload: Ctrl+Shift+U + +ocr: + enabled: false # 是否启用 OCR + auto_copy: false # 识别后自动复制 +``` ## 项目结构 ``` CutThenThink/ ├── src/ -│ ├── gui/ # GUI组件 -│ │ ├── widgets/ # 自定义控件 -│ │ └── styles/ # 样式文件 -│ ├── core/ # 核心功能 -│ ├── models/ # 数据模型 -│ ├── config/ # 配置管理 -│ └── utils/ # 工具函数 -├── data/ # 数据目录 -│ ├── images/ # 截图存储 -│ └── database/ # 数据库文件 -├── requirements.txt # 项目依赖 -├── .gitignore # Git忽略文件 -└── README.md # 项目说明 +│ ├── main.py # 入口 +│ ├── config.py # 简化配置 +│ ├── core/ +│ │ ├── database.py # SQLite 存储 +│ │ ├── screenshot.py # 截图功能 +│ │ └── uploader.py # 上传功能 +│ ├── gui/ +│ │ └── main_window.py # 主窗口 +│ ├── plugins/ # 可选插件 +│ │ └── ocr.py # RapidOCR 插件 +│ └── utils/ # 工具函数 +├── requirements.txt # 核心依赖 +├── requirements-ocr.txt # 可选 OCR +└── config.yaml # 配置文件 ``` -## 开发计划 +## 开发 -- [x] 项目初始化 -- [ ] 基础GUI框架搭建 -- [ ] 截图功能实现 -- [ ] OCR识别集成 -- [ ] AI分析功能 -- [ ] 数据库存储 -- [ ] 历史记录管理 -- [ ] 配置系统 -- [ ] 快捷键支持 -- [ ] 打包发布 +```bash +# 安装开发依赖 +pip install -r requirements.txt -## 贡献指南 +# 运行 +python src/main.py +``` -欢迎提交Issue和Pull Request! +## 构建 + +使用 PyInstaller 打包: + +```bash +pyinstaller CutThenThink.spec +``` ## 许可证 MIT License - -## 联系方式 - -- 项目地址: [GitHub Repository] -- 问题反馈: [Issues] diff --git a/docs/需求分析报告.md b/docs/需求分析报告.md new file mode 100644 index 0000000..bbd9567 --- /dev/null +++ b/docs/需求分析报告.md @@ -0,0 +1,302 @@ +# CutThenThink 需求分析报告 + +## 项目现状评估 + +### 原始需求回顾 + +**CutThenThink** 应当是一个**轻量级截图工具**,核心功能包括: + +1. **截图功能** - 支持多种截图方式 +2. **云端存储** - 数据存储在云端 +3. **OCR 扫描** - 文字识别,云端部署 +4. **AI 分类解析** - 智能分类和内容生成,服务提供商 API 或本地 LLM + +### 当前实现状态 + +#### ✅ 已完成的功能模块 + +| 模块 | 状态 | 说明 | +|------|------|------| +| 数据模型 | 完成 | SQLAlchemy Record 模型 | +| 配置管理 | 完成 | 完善的 Settings 配置系统 | +| OCR 模块 | 部分完成 | 仅支持云端 API,但无本地降级方案 | +| AI 模块 | 完成 | 支持 OpenAI/Claude/通义/Ollama | +| 存储模块 | 部分完成 | 仅有 JSON 文件存储,无云端存储 | +| 处理流程 | 完成 | OCR→AI→Storage 流程整合 | +| GUI 框架 | 部分完成 | PyQt6 主窗口和部分组件 | + +#### ❌ 存在的问题 + +### 问题一:架构过度复杂 + +**设计文档中的架构**: +``` +多用户系统 → 云端 API → 复杂配置 +``` + +**实际需求**: +``` +轻量级工具 → 简单配置 → 本地使用为主 +``` + +**具体表现**: +1. 过度的配置系统(AI/OCR/云端/界面/高级 5 大类配置) +2. 支持多种云存储(S3/OSS/COS/MinIO)但无实际实现 +3. 多用户隔离设计(实际是单用户工具) + +### 问题二:OCR 功能不完整 + +**设计文档承诺**: +- 内置轻量 OCR(PaddleOCR 本地运行) +- 云端 OCR API(百度/腾讯/阿里云) +- 自动降级机制 + +**当前实现**: +- 仅支持云端 API 调用 +- 无任何本地 OCR 实现 +- 无任何第三方 OCR 服务集成 +- **OCR 功能实际上无法使用** + +### 问题三:云端存储未实现 + +**设计文档承诺**: +- WebDAV 支持 +- 阿里云 OSS 支持 +- AWS S3 支持 +- 同步状态显示 + +**当前实现**: +- 仅有简单的 JSON 文件存储 +- 无任何云端存储实现 +- 云存储配置类存在但无实际功能 + +### 问题四:AI 分类功能依赖外部服务 + +**现状**: +- 完全依赖第三方 API(OpenAI/Claude/通义千问) +- 虽然支持 Ollama 本地模型,但需要用户自行部署 +- 无离线工作能力 + +**问题**: +- 需要配置 API Key +- 产生额外费用 +- 网络依赖 + +### 问题五:项目定位模糊 + +| 设计文档 | 实际需求 | 现状 | +|---------|---------|------| +| 多用户系统 | 单人工具 | 架构过于复杂 | +| 云端优先 | 本地为主 | 云功能未实现 | +| 智能分类器 | 简单分类工具 | AI 调用复杂 | +| 完整应用 | 轻量工具 | 依赖过多 | + +--- + +## 重新定义的需求 + +### 核心定位 + +**CutThenThink** 是一个**轻量级个人截图管理工具**,专注于: +1. 快速截图 +2. 文字识别 +3. 智能分类 +4. 本地存储为主,可选云端同步 + +### 最小可行产品(MVP)功能 + +#### Phase 1: 核心功能(必须) + +1. **截图功能** + - 全局快捷键截图 + - 区域选择截图 + - 剪贴板图片检测 + +2. **OCR 识别** + - 本地 OCR 引擎(PaddleOCR 轻量版) + - 云端 OCR API 作为可选增强 + +3. **AI 分类** + - 简单规则分类(基于关键词) + - AI API 分类作为可选功能 + +4. **本地存储** + - SQLite 本地数据库 + - 图片文件存储 + - 基础 CRUD 操作 + +5. **简单 GUI** + - 主窗口 + - 截图预览 + - 历史记录浏览 + +#### Phase 2: 增强功能(可选) + +1. **云端同步** + - 简单的 WebDAV 同步 + - 或单个云服务商集成 + +2. **高级 AI** + - OpenAI/Claude API 集成 + - 本地 Ollama 支持 + +3. **批量操作** + - 批量导入图片 + - 批量导出 + +#### Phase 3: 优化功能(未来) + +1. 图片标注/编辑 +2. 高级搜索 +3. 统计报表 +4. 多设备同步 + +--- + +## 技术栈简化建议 + +### 当前技术栈问题 + +| 组件 | 当前 | 问题 | +|------|------|------| +| GUI | PyQt6 | 功能过多,配置复杂 | +| 数据库 | SQLAlchemy | 过度工程 | +| OCR | 云端 API | 无法使用 | +| AI | 多个 API | 配置复杂 | + +### 建议的技术栈 + +| 组件 | 建议 | 理由 | +|------|------|------| +| GUI | PyQt6 (简化) | 保持不变,但简化功能 | +| 数据库 | SQLite 原生 | 去掉 ORM,简化代码 | +| OCR | PaddleOCR 轻量版 | 本地运行,不依赖网络 | +| AI | 规则 + 可选 API | 基础功能离线可用 | +| 存储 | 文件系统 | 简单直接 | + +--- + +## 代码重构建议 + +### 1. 简化配置系统 + +**当前**:5 大类配置(AI/OCR/云端/界面/高级) + +**建议**: +```yaml +# 简化配置文件 config.yaml +ocr: + engine: local # local 或 cloud + +ai: + enabled: false # 默认关闭 + provider: "" # 可选配置 + +storage: + type: local # local 或 webdav + +hotkeys: + screenshot: Ctrl+Shift+A +``` + +### 2. 实现本地 OCR + +```python +# 简化的 OCR 模块 +from paddleocr import PaddleOCR + +class LocalOCR: + def __init__(self): + self.ocr = PaddleOCR(use_angle_cls=True, lang='ch') + + def recognize(self, image_path): + result = self.ocr.ocr(image_path, cls=True) + return self._parse_result(result) +``` + +### 3. 简化数据模型 + +```python +# 简化的数据库模型 +import sqlite3 + +class RecordDB: + def __init__(self, path): + self.conn = sqlite3.connect(path) + self._init_table() + + def add(self, image_path, ocr_text, category): + # 简单的插入操作 + pass +``` + +### 4. 规则优先的 AI 分类 + +```python +# 简单规则分类 +class RuleClassifier: + RULES = { + 'TODO': ['待办', '任务', '完成', 'TODO'], + 'NOTE': ['笔记', '记录', '会议'], + # ... + } + + def classify(self, text): + for category, keywords in self.RULES.items(): + if any(kw in text for kw in keywords): + return category + return 'TEXT' +``` + +--- + +## 开发优先级 + +### P0 (立即修复) + +1. **实现本地 OCR** - 这是核心功能,必须可用 +2. **简化配置系统** - 降低使用门槛 +3. **基础 GUI 完善** - 确保核心流程可用 + +### P1 (短期完成) + +1. 实现规则分类系统 +2. 完善本地存储功能 +3. 添加批量操作支持 + +### P2 (长期优化) + +1. 云端同步功能 +2. AI API 集成 +3. 高级编辑功能 + +--- + +## 总结 + +### 核心问题 + +CutThenThink 项目的主要问题在于**过度设计**: +- 功能过多但实现不足 +- 架构复杂但缺少核心 +- 配置繁重但难以使用 + +### 解决方向 + +1. **回归本质**:轻量级截图工具 +2. **核心优先**:OCR + 分类 + 存储 +3. **本地为主**:确保离线可用 +4. **渐进增强**:从简单到复杂 + +### 下一步行动 + +1. 确认 MVP 功能范围 +2. 实现 PaddleOCR 本地集成 +3. 简化配置系统 +4. 完善基础 GUI 流程 +5. 测试端到端功能 + +--- + +*报告生成时间:2025年* +*项目路径:CutThenThink* diff --git a/docs/需求分析报告_v2.md b/docs/需求分析报告_v2.md new file mode 100644 index 0000000..edb8932 --- /dev/null +++ b/docs/需求分析报告_v2.md @@ -0,0 +1,257 @@ +# CutThenThink 需求分析报告 v2.0 + +## 项目重新定位 + +**CutThenThink** 是一个**极简轻量级截图上传工具** + +### 核心功能(最小化) + +| 功能 | 说明 | +|------|------| +| 截图 | 全屏、区域、窗口截图 | +| 上传 | 上传到配置的云端服务 | +| 批量上传 | 选择多张图片批量上传 | +| 分类查看 | 按分类浏览历史记录 | + +### 明确移除的功能 + +- ❌ 移除 PaddleOCR 本地模型(太重) +- ❌ 移除 torch/torchvision 依赖 +- ❌ 移除 transformers 依赖 +- ❌ 移除复杂的 AI 分类系统 +- ❌ 移除多用户系统设计 + +### 可选功能 + +- 🔄 **OCR**:可选插件,使用 RapidOCR(轻量级 ONNX) +- 🔄 **AI 分类**:可选云端 API,不作为核心依赖 + +--- + +## 参考项目调研 + +### RapidOCR - 轻量级 OCR 方案 + +**为什么选择 RapidOCR?** + +| 对比项 | PaddleOCR | RapidOCR | +|--------|-----------|-----------| +| 安装大小 | ~200MB+ | ~10MB | +| 依赖复杂度 | 高(paddlepaddle) | 低(onnxruntime) | +| 安装命令 | `pip install paddleocr paddlepaddle` | `pip install rapidocr onnxruntime` | +| 启动速度 | 较慢 | 极快 | +| 可选性 | 难以做成可选 | 易于做成可选插件 | + +**使用示例**: +```python +from rapidocr import RapidOCR + +engine = RapidOCR() +result = engine("screenshot.png") +print(result[1]) # 识别的文本 +``` + +### ShareX - 成功的上传工具参考 + +**核心特点**: +- 截图 + 编辑 + 上传 一体化 +- 支持多种上传目标(自建服务器、云存储) +- 可自定义工作流 +- 快捷键优先 + +**可借鉴的设计**: +1. 简洁的配置界面 +2. 上传历史记录 +3. 自定义上传目标 + +--- + +## 简化后的技术栈 + +### 核心依赖(必须) + +``` +PyQt6 # GUI +requests # 上传请求 +Pillow # 图片处理 +pyperclip # 剪贴板 +``` + +### 可选依赖(插件化) + +``` +rapidocr # OCR(可选安装) +onnxruntime # OCR 运行时(可选) +``` + +### 完全移除 + +``` +paddleocr # 移除 +paddlepaddle # 移除 +torch # 移除 +transformers # 移除 +openai # 移除(可用扩展支持) +anthropic # 移除(可用扩展支持) +SQLAlchemy # 移除(用原生 sqlite3) +``` + +--- + +## 简化的项目结构 + +``` +CutThenThink/ +├── src/ +│ ├── main.py # 入口 +│ ├── core/ +│ │ ├── screenshot.py # 截图功能 +│ │ ├── uploader.py # 上传功能 +│ │ └── database.py # 简单 SQLite +│ ├── gui/ +│ │ ├── main_window.py # 主窗口 +│ │ ├── screenshot_widget.py # 截图组件 +│ │ └── upload_widget.py # 上传组件 +│ ├── plugins/ # 可选插件 +│ │ └── ocr.py # OCR 插件(可选) +│ └── config.py # 简化配置 +├── data/ +│ └── screenshots/ # 截图存储 +├── requirements.txt # 核心依赖 +├── requirements-ocr.txt # 可选 OCR +└── config.yaml # 用户配置 +``` + +--- + +## 简化配置 + +```yaml +# config.yaml - 最小配置 +app: + theme: dark + +upload: + # 支持:imgur、自建、telegraph、s3等 + provider: custom + endpoint: https://your-server.com/upload + api_key: your-key + +screenshot: + format: png + save_path: ~/Pictures/Screenshots + +hotkeys: + capture: Ctrl+Shift+A + upload: Ctrl+Shift+U + +# 可选,如果未安装 rapidocr 则忽略 +ocr: + enabled: false + auto_copy: false +``` + +--- + +## MVP 功能清单 + +### P0 - 核心功能 + +- [x] 全局快捷键截图 +- [ ] 区域选择截图 +- [ ] 图片上传到服务器 +- [ ] 简单的历史记录 +- [ ] 复制链接到剪贴板 + +### P1 - 增强功能 + +- [ ] 批量上传 +- [ ] 自定义上传目标 +- [ ] 截图编辑(简单标注) +- [ ] 分类管理 + +### P2 - 可选插件 + +- [ ] RapidOCR 集成 +- [ ] 云端 AI 分类 +- [ ] 更多上传服务 + +--- + +## 实现计划 + +### 第一阶段:核心功能(1-2周) + +1. 简化项目结构,移除不必要依赖 +2. 实现基础截图功能 +3. 实现简单上传功能 +4. 实现 SQLite 存储 +5. 简洁的主窗口 + +### 第二阶段:增强功能(1周) + +1. 批量上传 +2. 上传历史管理 +3. 分类浏览 + +### 第三阶段:可选插件(按需) + +1. RapidOCR 插件 +2. 云端服务集成 + +--- + +## 依赖对比 + +### 当前项目依赖 + +``` +PyQt6>=6.7.0 +SQLAlchemy>=2.0.36 +openai>=1.0.0 +anthropic>=0.18.0 +requests>=2.31.0 +pyyaml>=6.0.1 +pillow>=10.0.0 +pyperclip>=1.8.2 +``` + +### 简化后核心依赖 + +``` +PyQt6>=6.7.0 +requests>=2.31.0 +pillow>=10.0.0 +pyperclip>=1.8.2 +``` + +### 可选 OCR 依赖 + +``` +rapidocr>=1.3.0 +onnxruntime>=1.16.0 +``` + +--- + +## 总结 + +### 核心变化 + +1. **定位变化**:从"智能截图工具"变为"截图上传工具" +2. **功能简化**:OCR 和 AI 变为可选插件 +3. **依赖精简**:移除所有重型 ML 库 +4. **架构简化**:去除 ORM 和复杂配置 + +### 目标 + +一个**开箱即用**的轻量级截图上传工具: +- 安装简单(`pip install`) +- 启动快速 +- 配置简洁 +- 功能专注 + +--- + +*更新时间:2025年* +*版本:v2.0 - 简化版* diff --git a/requirements-ocr.txt b/requirements-ocr.txt new file mode 100644 index 0000000..4a17fb5 --- /dev/null +++ b/requirements-ocr.txt @@ -0,0 +1,8 @@ +# CutThenThink - 可选 OCR 插件 +# 安装:pip install -r requirements-ocr.txt + +# 轻量级 OCR 引擎 +rapidocr>=1.3.0 + +# ONNX 运行时 +onnxruntime>=1.16.0 diff --git a/requirements.txt b/requirements.txt index 797fe09..5a17043 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,17 @@ -# CutThenThink 纯云端版本依赖 -# 本版本使用云端 API 进行 OCR 和 AI 处理,无需任何本地 ML 库 +# CutThenThink - 极简截图上传工具 +# 核心依赖 -# GUI框架 +# GUI 框架 PyQt6>=6.7.0 -# 数据库 -SQLAlchemy>=2.0.36 +# 图片处理 +Pillow>=10.0.0 -# AI服务(API调用) -openai>=1.0.0 -anthropic>=0.18.0 - -# 工具库 +# HTTP 请求 requests>=2.31.0 -pyyaml>=6.0.1 -pillow>=10.0.0 + +# 剪贴板 pyperclip>=1.8.2 + +# 配置文件 +pyyaml>=6.0.1 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..eb9d707 --- /dev/null +++ b/src/config.py @@ -0,0 +1,170 @@ +""" +简化配置模块 +极简配置管理,支持 YAML 配置文件 +""" +import os +import yaml +from pathlib import Path +from dataclasses import dataclass, field, asdict +from typing import Optional, List + + +@dataclass +class UploadConfig: + """上传配置""" + provider: str = "custom" # custom, imgur, telegraph, s3 + endpoint: str = "" + api_key: str = "" + auto_copy: bool = True # 上传后自动复制链接 + + +@dataclass +class ScreenshotConfig: + """截图配置""" + format: str = "png" # png, jpg, webp + save_path: str = "~/Pictures/Screenshots" + quality: int = 95 # jpg 质量 + + +@dataclass +class HotkeyConfig: + """快捷键配置""" + capture: str = "Ctrl+Shift+A" # 截图 + region: str = "Ctrl+Shift+R" # 区域截图 + upload: str = "Ctrl+Shift+U" # 上传最后截图 + + +@dataclass +class OCRConfig: + """OCR 配置(可选)""" + enabled: bool = False + auto_copy: bool = False + language: str = "ch" # ch, en + + +@dataclass +class AppConfig: + """应用配置""" + theme: str = "dark" # dark, light, auto + language: str = "zh_CN" + tray_icon: bool = True + start_minimized: bool = False + + # 子配置 + upload: UploadConfig = field(default_factory=UploadConfig) + screenshot: ScreenshotConfig = field(default_factory=ScreenshotConfig) + hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig) + ocr: OCRConfig = field(default_factory=OCRConfig) + + +class Config: + """配置管理器""" + + DEFAULT_CONFIG_PATH = Path.home() / ".cutthenthink" / "config.yaml" + + def __init__(self, config_path: Optional[Path] = None): + self.config_path = Path(config_path) if config_path else self.DEFAULT_CONFIG_PATH + self._config: Optional[AppConfig] = None + + def load(self) -> AppConfig: + """加载配置""" + if not self.config_path.exists(): + # 创建默认配置 + self._config = AppConfig() + self.save() + return self._config + + try: + with open(self.config_path, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) or {} + + self._config = AppConfig( + theme=data.get('theme', 'dark'), + language=data.get('language', 'zh_CN'), + tray_icon=data.get('tray_icon', True), + start_minimized=data.get('start_minimized', False), + upload=self._load_upload(data.get('upload', {})), + screenshot=self._load_screenshot(data.get('screenshot', {})), + hotkeys=self._load_hotkeys(data.get('hotkeys', {})), + ocr=self._load_ocr(data.get('ocr', {})), + ) + return self._config + + except Exception as e: + print(f"配置加载失败,使用默认配置: {e}") + self._config = AppConfig() + return self._config + + def _load_upload(self, data: dict) -> UploadConfig: + return UploadConfig( + provider=data.get('provider', 'custom'), + endpoint=data.get('endpoint', ''), + api_key=data.get('api_key', ''), + auto_copy=data.get('auto_copy', True), + ) + + def _load_screenshot(self, data: dict) -> ScreenshotConfig: + return ScreenshotConfig( + format=data.get('format', 'png'), + save_path=data.get('save_path', '~/Pictures/Screenshots'), + quality=data.get('quality', 95), + ) + + def _load_hotkeys(self, data: dict) -> HotkeyConfig: + return HotkeyConfig( + capture=data.get('capture', 'Ctrl+Shift+A'), + region=data.get('region', 'Ctrl+Shift+R'), + upload=data.get('upload', 'Ctrl+Shift+U'), + ) + + def _load_ocr(self, data: dict) -> OCRConfig: + return OCRConfig( + enabled=data.get('enabled', False), + auto_copy=data.get('auto_copy', False), + language=data.get('language', 'ch'), + ) + + def save(self) -> None: + """保存配置""" + if self._config is None: + return + + try: + # 确保目录存在 + self.config_path.parent.mkdir(parents=True, exist_ok=True) + + data = { + 'theme': self._config.theme, + 'language': self._config.language, + 'tray_icon': self._config.tray_icon, + 'start_minimized': self._config.start_minimized, + 'upload': asdict(self._config.upload), + 'screenshot': asdict(self._config.screenshot), + 'hotkeys': asdict(self._config.hotkeys), + 'ocr': asdict(self._config.ocr), + } + + with open(self.config_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False) + + except Exception as e: + print(f"配置保存失败: {e}") + + @property + def app(self) -> AppConfig: + """获取应用配置""" + if self._config is None: + self._config = self.load() + return self._config + + +# 全局配置实例 +_global_config: Optional[Config] = None + + +def get_config() -> Config: + """获取全局配置""" + global _global_config + if _global_config is None: + _global_config = Config() + return _global_config diff --git a/src/config/__init__.py b/src/config/__init__.py deleted file mode 100644 index 3a9bfa0..0000000 --- a/src/config/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -配置管理模块 -""" - -from src.config.settings import Settings, get_config - -__all__ = ['Settings', 'get_config'] diff --git a/src/config/settings.py b/src/config/settings.py deleted file mode 100644 index dc96b0e..0000000 --- a/src/config/settings.py +++ /dev/null @@ -1,438 +0,0 @@ -""" -配置管理模块 - -负责管理应用程序的所有配置,包括: -- AI 配置(API keys, 模型选择, 提供商) -- OCR 配置(本地/云端选择, API keys) -- 云存储配置(类型, endpoint, 凭证) -- 界面配置(主题, 快捷键) -""" - -import os -import yaml -from pathlib import Path -from typing import Optional, Dict, Any, List -from dataclasses import dataclass, field, asdict -from enum import Enum - - -class ConfigError(Exception): - """配置错误异常""" - pass - - -class AIProvider(str, Enum): - """AI 提供商枚举""" - OPENAI = "openai" - ANTHROPIC = "anthropic" - AZURE = "azure" - CUSTOM = "custom" - - -class OCRMode(str, Enum): - """OCR 模式枚举""" - CLOUD = "cloud" # 云端 OCR API - - -class CloudStorageType(str, Enum): - """云存储类型枚举""" - NONE = "none" # 不使用云存储 - S3 = "s3" # AWS S3 - OSS = "oss" # 阿里云 OSS - COS = "cos" # 腾讯云 COS - MINIO = "minio" # MinIO - - -class Theme(str, Enum): - """界面主题枚举""" - LIGHT = "light" - DARK = "dark" - AUTO = "auto" - - -@dataclass -class AIConfig: - """AI 配置""" - provider: AIProvider = AIProvider.ANTHROPIC - api_key: str = "" - model: str = "claude-3-5-sonnet-20241022" - temperature: float = 0.7 - max_tokens: int = 4096 - timeout: int = 60 - base_url: str = "" # 用于自定义或 Azure - extra_params: Dict[str, Any] = field(default_factory=dict) - - def validate(self) -> None: - """验证 AI 配置""" - if not self.api_key and self.provider != AIProvider.CUSTOM: - raise ConfigError(f"AI API key 不能为空(提供商: {self.provider})") - - if self.temperature < 0 or self.temperature > 2: - raise ConfigError("temperature 必须在 0-2 之间") - - if self.max_tokens < 1: - raise ConfigError("max_tokens 必须大于 0") - - if self.timeout < 1: - raise ConfigError("timeout 必须大于 0") - - -@dataclass -class OCRConfig: - """OCR 配置 - 纯云端版本""" - mode: OCRMode = OCRMode.CLOUD - provider: str = "custom" # OCR 提供商: baidu/tencent/aliyun/custom - api_key: str = "" # 云端 OCR API key - api_secret: str = "" # 云端 OCR API secret(部分服务商需要) - api_endpoint: str = "" # 云端 OCR endpoint - lang: str = "ch" # 语言:ch(中文), en(英文), etc. - timeout: int = 30 - - def validate(self) -> None: - """验证 OCR 配置""" - if self.mode == OCRMode.CLOUD and not self.api_endpoint: - raise ConfigError("云端 OCR 模式需要指定 api_endpoint") - - -@dataclass -class CloudStorageConfig: - """云存储配置""" - type: CloudStorageType = CloudStorageType.NONE - endpoint: str = "" - access_key: str = "" - secret_key: str = "" - bucket: str = "" - region: str = "" - timeout: int = 30 - - def validate(self) -> None: - """验证云存储配置""" - if self.type == CloudStorageType.NONE: - return - - if not self.endpoint: - raise ConfigError(f"云存储 {self.type} 需要指定 endpoint") - - if not self.access_key or not self.secret_key: - raise ConfigError(f"云存储 {self.type} 需要指定 access_key 和 secret_key") - - if not self.bucket: - raise ConfigError(f"云存储 {self.type} 需要指定 bucket") - - -@dataclass -class Hotkey: - """快捷键配置""" - screenshot: str = "Ctrl+Shift+A" # 截图快捷键 - ocr: str = "Ctrl+Shift+O" # OCR 识别快捷键 - quick_capture: str = "Ctrl+Shift+X" # 快速捕获 - show_hide: str = "Ctrl+Shift+H" # 显示/隐藏主窗口 - - def validate(self) -> None: - """验证快捷键配置(简单格式检查)""" - # 这里可以做更复杂的快捷键格式验证 - pass - - -@dataclass -class UIConfig: - """界面配置""" - theme: Theme = Theme.AUTO - language: str = "zh_CN" # 界面语言 - window_width: int = 1200 - window_height: int = 800 - hotkeys: Hotkey = field(default_factory=Hotkey) - show_tray_icon: bool = True - minimize_to_tray: bool = True - auto_start: bool = False - - def validate(self) -> None: - """验证界面配置""" - if self.window_width < 400: - raise ConfigError("window_width 不能小于 400") - - if self.window_height < 300: - raise ConfigError("window_height 不能小于 300") - - self.hotkeys.validate() - - -@dataclass -class AdvancedConfig: - """高级配置""" - debug_mode: bool = False - log_level: str = "INFO" - log_file: str = "" - max_log_size: int = 10 # MB - backup_count: int = 5 - cache_dir: str = "" - temp_dir: str = "" - max_cache_size: int = 500 # MB - - def validate(self) -> None: - """验证高级配置""" - valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - if self.log_level.upper() not in valid_log_levels: - raise ConfigError(f"log_level 必须是以下之一: {', '.join(valid_log_levels)}") - - if self.max_log_size < 1: - raise ConfigError("max_log_size 不能小于 1") - - if self.backup_count < 0: - raise ConfigError("backup_count 不能为负数") - - -@dataclass -class Settings: - """主配置类""" - ai: AIConfig = field(default_factory=AIConfig) - ocr: OCRConfig = field(default_factory=OCRConfig) - cloud_storage: CloudStorageConfig = field(default_factory=CloudStorageConfig) - ui: UIConfig = field(default_factory=UIConfig) - advanced: AdvancedConfig = field(default_factory=AdvancedConfig) - - def __post_init__(self): - """初始化后处理,确保嵌套配置是正确的类型""" - if isinstance(self.ai, dict): - self.ai = AIConfig(**self.ai) - if isinstance(self.ocr, dict): - self.ocr = OCRConfig(**self.ocr) - if isinstance(self.cloud_storage, dict): - self.cloud_storage = CloudStorageConfig(**self.cloud_storage) - if isinstance(self.ui, dict): - self.ui = UIConfig(**self.ui) - if isinstance(self.advanced, dict): - self.advanced = AdvancedConfig(**self.advanced) - elif isinstance(self.ui.hotkeys, dict): - self.ui.hotkeys = Hotkey(**self.ui.hotkeys) - - def validate(self) -> None: - """验证所有配置""" - self.ai.validate() - self.ocr.validate() - self.cloud_storage.validate() - self.ui.validate() - self.advanced.validate() - - def to_dict(self) -> Dict[str, Any]: - """转换为字典,将枚举类型转换为字符串值""" - def enum_to_value(obj): - """递归转换枚举为字符串值""" - if isinstance(obj, Enum): - return obj.value - elif isinstance(obj, dict): - return {k: enum_to_value(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [enum_to_value(item) for item in obj] - else: - return obj - - return { - 'ai': enum_to_value(asdict(self.ai)), - 'ocr': enum_to_value(asdict(self.ocr)), - 'cloud_storage': enum_to_value(asdict(self.cloud_storage)), - 'ui': enum_to_value(asdict(self.ui)), - 'advanced': enum_to_value(asdict(self.advanced)) - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Settings': - """从字典创建配置""" - return cls( - ai=AIConfig(**data.get('ai', {})), - ocr=OCRConfig(**data.get('ocr', {})), - cloud_storage=CloudStorageConfig(**data.get('cloud_storage', {})), - ui=UIConfig(**data.get('ui', {})), - advanced=AdvancedConfig(**data.get('advanced', {})) - ) - - -class SettingsManager: - """配置管理器""" - - DEFAULT_CONFIG_DIR = Path.home() / '.cutthenthink' - DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / 'config.yaml' - - def __init__(self, config_path: Optional[Path] = None): - """ - 初始化配置管理器 - - Args: - config_path: 配置文件路径,默认为 ~/.cutthenthink/config.yaml - """ - self.config_path = Path(config_path) if config_path else self.DEFAULT_CONFIG_FILE - self._settings: Optional[Settings] = None - - def load(self, validate: bool = False) -> Settings: - """ - 加载配置 - - Args: - validate: 是否验证配置(默认 False,首次加载时可能缺少 API key) - - Returns: - Settings: 配置对象 - """ - if not self.config_path.exists(): - # 配置文件不存在,创建默认配置 - self._settings = Settings() - self.save(self._settings) - return self._settings - - try: - with open(self.config_path, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) or {} - - self._settings = Settings.from_dict(data) - - if validate: - self._settings.validate() - - return self._settings - - except yaml.YAMLError as e: - raise ConfigError(f"配置文件 YAML 格式错误: {e}") - except Exception as e: - raise ConfigError(f"加载配置失败: {e}") - - def save(self, settings: Optional[Settings] = None) -> None: - """ - 保存配置 - - Args: - settings: 要保存的配置对象,为 None 时保存当前配置 - """ - if settings is None: - settings = self._settings - - if settings is None: - raise ConfigError("没有可保存的配置") - - try: - # 确保配置目录存在 - self.config_path.parent.mkdir(parents=True, exist_ok=True) - - # 转换为字典并保存 - data = settings.to_dict() - - with open(self.config_path, 'w', encoding='utf-8') as f: - yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False) - - self._settings = settings - - except Exception as e: - raise ConfigError(f"保存配置失败: {e}") - - def reset(self) -> Settings: - """ - 重置为默认配置 - - Returns: - Settings: 默认配置对象 - """ - self._settings = Settings() - self.save(self._settings) - return self._settings - - @property - def settings(self) -> Settings: - """ - 获取当前配置(懒加载) - - Returns: - Settings: 配置对象 - """ - if self._settings is None: - self._settings = self.load() - return self._settings - - def get(self, key_path: str, default: Any = None) -> Any: - """ - 获取配置值(支持嵌套路径,如 'ai.provider') - - Args: - key_path: 配置键路径,用点分隔 - default: 默认值 - - Returns: - 配置值 - """ - keys = key_path.split('.') - value = self.settings - - for key in keys: - if hasattr(value, key): - value = getattr(value, key) - else: - return default - - return value - - def set(self, key_path: str, value: Any) -> None: - """ - 设置配置值(支持嵌套路径,如 'ai.provider') - - Args: - key_path: 配置键路径,用点分隔 - value: 要设置的值 - """ - keys = key_path.split('.') - obj = self.settings - - # 导航到父对象 - for key in keys[:-1]: - if hasattr(obj, key): - obj = getattr(obj, key) - else: - raise ConfigError(f"配置路径无效: {key_path}") - - # 设置最终值 - last_key = keys[-1] - if hasattr(obj, last_key): - # 处理枚举类型 - field_value = getattr(obj.__class__, last_key) - if hasattr(field_value, 'type') and isinstance(field_value.type, type) and issubclass(field_value.type, Enum): - # 如果是枚举类型,尝试转换 - try: - value = field_value.type(value) - except ValueError: - raise ConfigError(f"无效的枚举值: {value}") - - setattr(obj, last_key, value) - else: - raise ConfigError(f"配置键不存在: {last_key}") - - # 保存配置 - self.save() - - -# 全局配置管理器实例 -_global_settings_manager: Optional[SettingsManager] = None - - -def get_config(config_path: Optional[Path] = None) -> SettingsManager: - """ - 获取全局配置管理器(单例模式) - - Args: - config_path: 配置文件路径,仅在首次调用时有效 - - Returns: - SettingsManager: 配置管理器实例 - """ - global _global_settings_manager - - if _global_settings_manager is None: - _global_settings_manager = SettingsManager(config_path) - - return _global_settings_manager - - -def get_settings() -> Settings: - """ - 获取当前配置的快捷方法 - - Returns: - Settings: 配置对象 - """ - return get_config().settings diff --git a/src/core/ai.py b/src/core/ai.py deleted file mode 100644 index d95efcf..0000000 --- a/src/core/ai.py +++ /dev/null @@ -1,680 +0,0 @@ -""" -AI 分类模块 - -负责调用不同的 AI 提供商进行文本分类和内容生成 -支持的提供商:OpenAI, Anthropic (Claude), 通义千问, 本地 Ollama -""" - -import json -import time -from typing import Optional, Dict, Any, List -from enum import Enum -from dataclasses import dataclass, field, asdict -import logging - -logger = logging.getLogger(__name__) - - -class CategoryType(str, Enum): - """文本分类类型枚举""" - TODO = "TODO" # 待办事项 - NOTE = "NOTE" # 笔记 - IDEA = "IDEA" # 灵感 - REF = "REF" # 参考资料 - FUNNY = "FUNNY" # 搞笑文案 - TEXT = "TEXT" # 纯文本 - - @classmethod - def all(cls) -> List[str]: - """获取所有分类类型""" - return [c.value for c in cls] - - @classmethod - def is_valid(cls, category: str) -> bool: - """验证分类是否有效""" - return category in cls.all() - - -@dataclass -class ClassificationResult: - """AI 分类结果数据结构""" - category: CategoryType # 分类类型 - confidence: float # 置信度 (0-1) - title: str # 生成的标题 - content: str # 生成的 Markdown 内容 - tags: List[str] = field(default_factory=list) # 提取的标签 - reasoning: str = "" # AI 的分类理由(可选) - raw_response: str = "" # 原始响应(用于调试) - - def to_dict(self) -> Dict[str, Any]: - """转换为字典""" - return { - 'category': self.category.value, - 'confidence': self.confidence, - 'title': self.title, - 'content': self.content, - 'tags': self.tags, - 'reasoning': self.reasoning, - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'ClassificationResult': - """从字典创建实例""" - return cls( - category=CategoryType(data['category']), - confidence=data.get('confidence', 0.0), - title=data.get('title', ''), - content=data.get('content', ''), - tags=data.get('tags', []), - reasoning=data.get('reasoning', ''), - ) - - -class AIError(Exception): - """AI 调用错误基类""" - pass - - -class AIAPIError(AIError): - """AI API 调用错误""" - pass - - -class AIRateLimitError(AIError): - """AI API 速率限制错误""" - pass - - -class AIAuthenticationError(AIError): - """AI 认证错误""" - pass - - -class AITimeoutError(AIError): - """AI 请求超时错误""" - pass - - -# 分类提示词模板 -CLASSIFICATION_PROMPT_TEMPLATE = """你是一个智能文本分类助手。请分析以下OCR识别的文本,将其分类为以下6种类型之一: - -## 分类类型说明 - -1. **TODO (待办事项)**:包含任务、待办清单、行动项、计划等内容 - - 特征:包含"待办"、"任务"、"完成"、"截止日期"等关键词 - - 例如:工作计划、购物清单、行动项列表 - -2. **NOTE (笔记)**:学习笔记、会议记录、知识整理、信息摘录 - - 特征:知识性、信息性内容,通常是学习或工作的记录 - - 例如:课程笔记、会议纪要、知识点总结 - -3. **IDEA (灵感)**:创新想法、产品思路、创意点子、灵感记录 - - 特征:创造性、前瞻性、头脑风暴相关 - - 例如:产品创意、写作灵感、改进建议 - -4. **REF (参考资料)**:需要保存的参考资料、文档片段、教程链接 - - 特征:信息密度高,作为后续参考使用 - - 例如:API文档、配置示例、技术教程 - -5. **FUNNY (搞笑文案)**:幽默段子、搞笑图片文字、娱乐内容 - - 特征:娱乐性、搞笑、轻松的内容 - - 例如:段子、表情包配文、搞笑对话 - -6. **TEXT (纯文本)**:不适合归入以上类别的普通文本 - - 特征:信息量较低或难以明确分类的内容 - - 例如:广告、通知、普通对话 - -## 任务要求 - -请分析以下文本,并以 JSON 格式返回分类结果: - -```json -{{ - "category": "分类类型(TODO/NOTE/IDEA/REF/FUNNY/TEXT之一)", - "confidence": 0.95, - "title": "生成的简短标题(不超过20字)", - "content": "根据文本内容整理成 Markdown 格式的结构化内容", - "tags": ["标签1", "标签2", "标签3"], - "reasoning": "选择该分类的理由(简短说明)" -}} -``` - -## 注意事项 -- content 字段要生成格式化的 Markdown 内容,使用列表、标题等结构化元素 -- 对于 TODO 类型,请用任务列表格式:- [ ] 任务1 -- 对于 NOTE 类型,请用清晰的标题和分段 -- 对于 IDEA 类型,突出创新点 -- 对于 REF 类型,保留关键信息和结构 -- 对于 FUNNY 类型,保留原文的趣味性 -- confidence 为 0-1 之间的浮点数,表示分类的置信度 -- 提取 3-5 个最相关的标签 - -## 待分析的文本 - -``` -{text} -``` - -请仅返回 JSON 格式,不要包含其他说明文字。 -""" - - -class AIClientBase: - """AI 客户端基类""" - - def __init__( - self, - api_key: str, - model: str, - temperature: float = 0.7, - max_tokens: int = 4096, - timeout: int = 60, - max_retries: int = 3, - retry_delay: float = 1.0, - ): - """ - 初始化 AI 客户端 - - Args: - api_key: API 密钥 - model: 模型名称 - temperature: 温度参数 (0-2) - max_tokens: 最大生成长度 - timeout: 请求超时时间(秒) - max_retries: 最大重试次数 - retry_delay: 重试延迟(秒) - """ - self.api_key = api_key - self.model = model - self.temperature = temperature - self.max_tokens = max_tokens - self.timeout = timeout - self.max_retries = max_retries - self.retry_delay = retry_delay - - def classify(self, text: str) -> ClassificationResult: - """ - 对文本进行分类 - - Args: - text: 待分类的文本 - - Returns: - ClassificationResult: 分类结果 - - Raises: - AIError: 分类失败 - """ - raise NotImplementedError("子类必须实现此方法") - - def _parse_json_response(self, response_text: str) -> Dict[str, Any]: - """ - 解析 JSON 响应 - - Args: - response_text: AI 返回的文本 - - Returns: - 解析后的字典 - - Raises: - AIError: JSON 解析失败 - """ - # 尝试直接解析 - try: - return json.loads(response_text.strip()) - except json.JSONDecodeError: - pass - - # 尝试提取 JSON 代码块 - if "```json" in response_text: - start = response_text.find("```json") + 7 - end = response_text.find("```", start) - if end != -1: - try: - json_str = response_text[start:end].strip() - return json.loads(json_str) - except json.JSONDecodeError: - pass - - # 尝试提取普通代码块 - if "```" in response_text: - start = response_text.find("```") + 3 - end = response_text.find("```", start) - if end != -1: - try: - json_str = response_text[start:end].strip() - return json.loads(json_str) - except json.JSONDecodeError: - pass - - # 尝试查找 { } 包围的 JSON - start = response_text.find("{") - end = response_text.rfind("}") - if start != -1 and end != -1 and end > start: - try: - json_str = response_text[start:end+1] - return json.loads(json_str) - except json.JSONDecodeError: - pass - - raise AIError(f"无法解析 AI 响应为 JSON: {response_text[:200]}...") - - def _retry_on_failure(self, func, *args, **kwargs): - """ - 在失败时重试 - - Args: - func: 要执行的函数 - *args: 位置参数 - **kwargs: 关键字参数 - - Returns: - 函数执行结果 - - Raises: - AIError: 重试次数用尽后仍然失败 - """ - last_error = None - - for attempt in range(self.max_retries): - try: - return func(*args, **kwargs) - except Exception as e: - last_error = e - logger.warning(f"AI 调用失败(尝试 {attempt + 1}/{self.max_retries}): {e}") - - # 最后一次不等待 - if attempt < self.max_retries - 1: - delay = self.retry_delay * (2 ** attempt) # 指数退避 - logger.info(f"等待 {delay:.1f} 秒后重试...") - time.sleep(delay) - - raise AIError(f"AI 调用失败,已重试 {self.max_retries} 次: {last_error}") - - -class OpenAIClient(AIClientBase): - """OpenAI 客户端""" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - try: - import openai - self.openai = openai - self.client = openai.OpenAI(api_key=self.api_key, timeout=self.timeout) - except ImportError: - raise AIError("OpenAI 库未安装,请运行: pip install openai") - - def classify(self, text: str) -> ClassificationResult: - """使用 OpenAI API 进行分类""" - - def _do_classify(): - prompt = CLASSIFICATION_PROMPT_TEMPLATE.format(text=text[:4000]) # 限制长度 - - try: - response = self.client.chat.completions.create( - model=self.model, - messages=[ - {"role": "system", "content": "你是一个专业的文本分类助手。"}, - {"role": "user", "content": prompt} - ], - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - - result_text = response.choices[0].message.content.strip() - - # 解析 JSON 响应 - result_dict = self._parse_json_response(result_text) - - # 验证分类 - category = result_dict.get('category', 'TEXT') - if not CategoryType.is_valid(category): - category = 'TEXT' - - return ClassificationResult( - category=CategoryType(category), - confidence=float(result_dict.get('confidence', 0.8)), - title=str(result_dict.get('title', '未命名'))[:50], - content=str(result_dict.get('content', text)), - tags=list(result_dict.get('tags', []))[:5], - reasoning=str(result_dict.get('reasoning', '')), - raw_response=result_text, - ) - - except self.openai.AuthenticationError as e: - raise AIAuthenticationError(f"OpenAI 认证失败: {e}") - except self.openai.RateLimitError as e: - raise AIRateLimitError(f"OpenAI API 速率限制: {e}") - except self.openai.APITimeoutError as e: - raise AITimeoutError(f"OpenAI API 请求超时: {e}") - except self.openai.APIError as e: - raise AIAPIError(f"OpenAI API 错误: {e}") - - return self._retry_on_failure(_do_classify) - - -class AnthropicClient(AIClientBase): - """Anthropic (Claude) 客户端""" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - try: - import anthropic - self.anthropic = anthropic - self.client = anthropic.Anthropic(api_key=self.api_key, timeout=self.timeout) - except ImportError: - raise AIError("Anthropic 库未安装,请运行: pip install anthropic") - - def classify(self, text: str) -> ClassificationResult: - """使用 Claude API 进行分类""" - - def _do_classify(): - prompt = CLASSIFICATION_PROMPT_TEMPLATE.format(text=text[:4000]) - - try: - response = self.client.messages.create( - model=self.model, - max_tokens=self.max_tokens, - temperature=self.temperature, - messages=[ - {"role": "user", "content": prompt} - ] - ) - - result_text = response.content[0].text.strip() - - # 解析 JSON 响应 - result_dict = self._parse_json_response(result_text) - - # 验证分类 - category = result_dict.get('category', 'TEXT') - if not CategoryType.is_valid(category): - category = 'TEXT' - - return ClassificationResult( - category=CategoryType(category), - confidence=float(result_dict.get('confidence', 0.8)), - title=str(result_dict.get('title', '未命名'))[:50], - content=str(result_dict.get('content', text)), - tags=list(result_dict.get('tags', []))[:5], - reasoning=str(result_dict.get('reasoning', '')), - raw_response=result_text, - ) - - except self.anthropic.AuthenticationError as e: - raise AIAuthenticationError(f"Claude 认证失败: {e}") - except self.anthropic.RateLimitError as e: - raise AIRateLimitError(f"Claude API 速率限制: {e}") - except self.anthropic.APITimeoutError as e: - raise AITimeoutError(f"Claude API 请求超时: {e}") - except self.anthropic.APIError as e: - raise AIAPIError(f"Claude API 错误: {e}") - - return self._retry_on_failure(_do_classify) - - -class QwenClient(AIClientBase): - """通义千问客户端 (兼容 OpenAI API)""" - - def __init__(self, base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1", **kwargs): - super().__init__(**kwargs) - self.base_url = base_url - try: - import openai - self.openai = openai - self.client = openai.OpenAI( - api_key=self.api_key, - base_url=self.base_url, - timeout=self.timeout - ) - except ImportError: - raise AIError("OpenAI 库未安装,请运行: pip install openai") - - def classify(self, text: str) -> ClassificationResult: - """使用通义千问 API 进行分类""" - - def _do_classify(): - prompt = CLASSIFICATION_PROMPT_TEMPLATE.format(text=text[:4000]) - - try: - response = self.client.chat.completions.create( - model=self.model, - messages=[ - {"role": "system", "content": "你是一个专业的文本分类助手。"}, - {"role": "user", "content": prompt} - ], - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - - result_text = response.choices[0].message.content.strip() - - # 解析 JSON 响应 - result_dict = self._parse_json_response(result_text) - - # 验证分类 - category = result_dict.get('category', 'TEXT') - if not CategoryType.is_valid(category): - category = 'TEXT' - - return ClassificationResult( - category=CategoryType(category), - confidence=float(result_dict.get('confidence', 0.8)), - title=str(result_dict.get('title', '未命名'))[:50], - content=str(result_dict.get('content', text)), - tags=list(result_dict.get('tags', []))[:5], - reasoning=str(result_dict.get('reasoning', '')), - raw_response=result_text, - ) - - except Exception as e: - if "authentication" in str(e).lower(): - raise AIAuthenticationError(f"通义千问认证失败: {e}") - elif "rate limit" in str(e).lower(): - raise AIRateLimitError(f"通义千问 API 速率限制: {e}") - elif "timeout" in str(e).lower(): - raise AITimeoutError(f"通义千问 API 请求超时: {e}") - else: - raise AIAPIError(f"通义千问 API 错误: {e}") - - return self._retry_on_failure(_do_classify) - - -class OllamaClient(AIClientBase): - """Ollama 本地模型客户端 (兼容 OpenAI API)""" - - def __init__(self, base_url: str = "http://localhost:11434/v1", **kwargs): - super().__init__(**kwargs) - self.base_url = base_url - try: - import openai - self.openai = openai - # Ollama 通常不需要 API key,使用任意值 - self.client = openai.OpenAI( - api_key=self.api_key or "ollama", - base_url=self.base_url, - timeout=self.timeout - ) - except ImportError: - raise AIError("OpenAI 库未安装,请运行: pip install openai") - - def classify(self, text: str) -> ClassificationResult: - """使用 Ollama 本地模型进行分类""" - - def _do_classify(): - prompt = CLASSIFICATION_PROMPT_TEMPLATE.format(text=text[:4000]) - - try: - response = self.client.chat.completions.create( - model=self.model, - messages=[ - {"role": "system", "content": "你是一个专业的文本分类助手。"}, - {"role": "user", "content": prompt} - ], - temperature=self.temperature, - max_tokens=self.max_tokens, - ) - - result_text = response.choices[0].message.content.strip() - - # 解析 JSON 响应 - result_dict = self._parse_json_response(result_text) - - # 验证分类 - category = result_dict.get('category', 'TEXT') - if not CategoryType.is_valid(category): - category = 'TEXT' - - return ClassificationResult( - category=CategoryType(category), - confidence=float(result_dict.get('confidence', 0.8)), - title=str(result_dict.get('title', '未命名'))[:50], - content=str(result_dict.get('content', text)), - tags=list(result_dict.get('tags', []))[:5], - reasoning=str(result_dict.get('reasoning', '')), - raw_response=result_text, - ) - - except Exception as e: - if "connection" in str(e).lower(): - raise AIError(f"无法连接到 Ollama 服务 ({self.base_url}): {e}") - else: - raise AIAPIError(f"Ollama API 错误: {e}") - - return self._retry_on_failure(_do_classify) - - -class AIClassifier: - """ - AI 分类器主类 - - 根据配置自动选择合适的 AI 客户端进行文本分类 - """ - - # 支持的提供商映射 - CLIENTS = { - "openai": OpenAIClient, - "anthropic": AnthropicClient, - "qwen": QwenClient, - "ollama": OllamaClient, - } - - @classmethod - def create_client( - cls, - provider: str, - api_key: str, - model: str, - **kwargs - ) -> AIClientBase: - """ - 创建 AI 客户端 - - Args: - provider: 提供商名称 (openai, anthropic, qwen, ollama) - api_key: API 密钥 - model: 模型名称 - **kwargs: 其他参数 - - Returns: - AI 客户端实例 - - Raises: - AIError: 不支持的提供商 - """ - provider_lower = provider.lower() - - if provider_lower not in cls.CLIENTS: - raise AIError( - f"不支持的 AI 提供商: {provider}. " - f"支持的提供商: {', '.join(cls.CLIENTS.keys())}" - ) - - client_class = cls.CLIENTS[provider_lower] - - # 根据不同提供商设置默认模型 - if not model: - default_models = { - "openai": "gpt-4o-mini", - "anthropic": "claude-3-5-sonnet-20241022", - "qwen": "qwen-turbo", - "ollama": "llama3.2", - } - model = default_models.get(provider_lower, "default") - - return client_class( - api_key=api_key, - model=model, - **kwargs - ) - - @classmethod - def classify( - cls, - text: str, - provider: str, - api_key: str, - model: str = "", - **kwargs - ) -> ClassificationResult: - """ - 对文本进行分类 - - Args: - text: 待分类的文本 - provider: 提供商名称 - api_key: API 密钥 - model: 模型名称 - **kwargs: 其他参数 - - Returns: - ClassificationResult: 分类结果 - """ - client = cls.create_client(provider, api_key, model, **kwargs) - return client.classify(text) - - -def create_classifier_from_config(ai_config) -> AIClassifier: - """ - 从配置对象创建 AI 分类器 - - Args: - ai_config: AI 配置对象 (来自 config.settings.AIConfig) - - Returns: - 配置好的 AI 客户端 - - Example: - >>> from src.config.settings import get_settings - >>> settings = get_settings() - >>> client = create_classifier_from_config(settings.ai) - >>> result = client.classify("待分析的文本") - """ - return AIClassifier.create_client( - provider=ai_config.provider.value, - api_key=ai_config.api_key, - model=ai_config.model, - temperature=ai_config.temperature, - max_tokens=ai_config.max_tokens, - timeout=ai_config.timeout, - ) - - -# 便捷函数 -def classify_text(text: str, ai_config) -> ClassificationResult: - """ - 使用配置的 AI 服务对文本进行分类 - - Args: - text: 待分类的文本 - ai_config: AI 配置对象 - - Returns: - ClassificationResult: 分类结果 - - Raises: - AIError: 分类失败 - """ - client = create_classifier_from_config(ai_config) - return client.classify(text) diff --git a/src/core/database.py b/src/core/database.py new file mode 100644 index 0000000..e4c245c --- /dev/null +++ b/src/core/database.py @@ -0,0 +1,234 @@ +""" +简化数据库模块 - 使用原生 SQLite +不使用 ORM,保持轻量 +""" +import sqlite3 +import json +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Optional, Any +from dataclasses import dataclass, asdict + + +@dataclass +class Record: + """记录数据结构""" + id: int + filename: str + filepath: str + upload_url: Optional[str] = None + category: str = "uncategorized" # uncategorized, work, personal, temp + ocr_text: Optional[str] = None + created_at: str = None + uploaded_at: str = None + file_size: int = 0 + + def __post_init__(self): + if self.created_at is None: + self.created_at = datetime.now().isoformat() + + +class Database: + """简化的数据库管理""" + + def __init__(self, db_path: Optional[Path] = None): + if db_path is None: + # 默认路径 + data_dir = Path.home() / ".cutthenthink" + data_dir.mkdir(parents=True, exist_ok=True) + db_path = data_dir / "records.db" + + self.db_path = db_path + self._conn: Optional[sqlite3.Connection] = None + self._init() + + def _init(self): + """初始化数据库""" + self._conn = sqlite3.connect(str(self.db_path)) + self._conn.row_factory = sqlite3.Row + self._create_tables() + + def _create_tables(self): + """创建表""" + cursor = self._conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + filepath TEXT NOT NULL, + upload_url TEXT, + category TEXT DEFAULT 'uncategorized', + ocr_text TEXT, + created_at TEXT, + uploaded_at TEXT, + file_size INTEGER DEFAULT 0 + ) + """) + + # 创建索引 + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_category + ON records(category) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_created_at + ON records(created_at DESC) + """) + + self._conn.commit() + + def add(self, record: Record) -> int: + """添加记录""" + cursor = self._conn.cursor() + cursor.execute(""" + INSERT INTO records ( + filename, filepath, upload_url, category, + ocr_text, created_at, uploaded_at, file_size + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + record.filename, + record.filepath, + record.upload_url, + record.category, + record.ocr_text, + record.created_at, + record.uploaded_at, + record.file_size, + )) + self._conn.commit() + return cursor.lastrowid + + def get(self, record_id: int) -> Optional[Record]: + """获取单条记录""" + cursor = self._conn.cursor() + cursor.execute("SELECT * FROM records WHERE id = ?", (record_id,)) + row = cursor.fetchone() + if row: + return self._row_to_record(row) + return None + + def get_all(self, category: Optional[str] = None, limit: int = 100) -> List[Record]: + """获取所有记录""" + cursor = self._conn.cursor() + + if category: + cursor.execute(""" + SELECT * FROM records + WHERE category = ? + ORDER BY created_at DESC + LIMIT ? + """, (category, limit)) + else: + cursor.execute(""" + SELECT * FROM records + ORDER BY created_at DESC + LIMIT ? + """, (limit,)) + + return [self._row_to_record(row) for row in cursor.fetchall()] + + def update(self, record_id: int, **kwargs) -> bool: + """更新记录""" + if not kwargs: + return False + + fields = [] + values = [] + for key, value in kwargs.items(): + if key in ['upload_url', 'category', 'ocr_text', 'uploaded_at']: + fields.append(f"{key} = ?") + values.append(value) + + if not fields: + return False + + values.append(record_id) + cursor = self._conn.cursor() + cursor.execute(f""" + UPDATE records SET {', '.join(fields)} + WHERE id = ? + """, values) + self._conn.commit() + return cursor.rowcount > 0 + + def delete(self, record_id: int) -> bool: + """删除记录""" + cursor = self._conn.cursor() + cursor.execute("DELETE FROM records WHERE id = ?", (record_id,)) + self._conn.commit() + return cursor.rowcount > 0 + + def search(self, keyword: str) -> List[Record]: + """搜索记录""" + cursor = self._conn.cursor() + pattern = f"%{keyword}%" + cursor.execute(""" + SELECT * FROM records + WHERE filename LIKE ? OR ocr_text LIKE ? + ORDER BY created_at DESC + """, (pattern, pattern)) + return [self._row_to_record(row) for row in cursor.fetchall()] + + def get_categories(self) -> List[str]: + """获取所有分类""" + cursor = self._conn.cursor() + cursor.execute(""" + SELECT DISTINCT category FROM records + ORDER BY category + """) + return [row[0] for row in cursor.fetchall()] + + def get_stats(self) -> Dict[str, Any]: + """获取统计信息""" + cursor = self._conn.cursor() + cursor.execute(""" + SELECT + COUNT(*) as total, + COUNT(DISTINCT category) as categories, + SUM(file_size) as total_size + FROM records + """) + row = cursor.fetchone() + return { + 'total': row[0] or 0, + 'categories': row[1] or 0, + 'total_size': row[2] or 0, + } + + def _row_to_record(self, row: sqlite3.Row) -> Record: + """将数据库行转换为 Record 对象""" + return Record( + id=row['id'], + filename=row['filename'], + filepath=row['filepath'], + upload_url=row['upload_url'], + category=row['category'], + ocr_text=row['ocr_text'], + created_at=row['created_at'], + uploaded_at=row['uploaded_at'], + file_size=row['file_size'], + ) + + def close(self): + """关闭连接""" + if self._conn: + self._conn.close() + self._conn = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + +# 全局数据库实例 +_global_db: Optional[Database] = None + + +def get_db() -> Database: + """获取全局数据库实例""" + global _global_db + if _global_db is None: + _global_db = Database() + return _global_db diff --git a/src/core/ocr.py b/src/core/ocr.py deleted file mode 100644 index 0efb1c1..0000000 --- a/src/core/ocr.py +++ /dev/null @@ -1,649 +0,0 @@ -""" -OCR 模块 - 纯云端版本 - -提供云端 API 文字识别功能: -- 云端 OCR API 调用(百度/腾讯/阿里云等) -- 图片预处理增强 -- 多语言支持(中/英/混合) - -注意:本版本不包含本地 OCR 引擎,所有 OCR 处理通过云端 API 完成。 -""" - -from abc import ABC, abstractmethod -from pathlib import Path -from typing import List, Optional, Dict, Any -from dataclasses import dataclass -from enum import Enum -import logging -import base64 -import io - -try: - from PIL import Image, ImageEnhance, ImageFilter -except ImportError: - raise ImportError( - "请安装图像处理库: pip install pillow" - ) - -try: - import requests -except ImportError: - raise ImportError( - "请安装 requests 库: pip install requests" - ) - -# 配置日志 -logger = logging.getLogger(__name__) - - -class OCRLanguage(str, Enum): - """OCR 支持的语言""" - CHINESE = "ch" # 中文 - ENGLISH = "en" # 英文 - MIXED = "ch_en" # 中英文混合 - - -class OCRProvider(str, Enum): - """OCR 云端服务提供商""" - BAIDU = "baidu" # 百度 OCR - TENCENT = "tencent" # 腾讯云 OCR - ALIYUN = "aliyun" # 阿里云 OCR - CUSTOM = "custom" # 自定义 API - - -@dataclass -class OCRResult: - """ - OCR 识别结果 - - Attributes: - text: 识别的文本内容 - confidence: 置信度 (0-1) - bbox: 文本框坐标 [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] - line_index: 行索引(从0开始) - """ - text: str - confidence: float - bbox: Optional[List[List[float]]] = None - line_index: int = 0 - - def __repr__(self) -> str: - return f"OCRResult(text='{self.text[:30]}...', confidence={self.confidence:.2f})" - - -@dataclass -class OCRBatchResult: - """ - OCR 批量识别结果 - - Attributes: - results: 所有的识别结果列表 - full_text: 完整文本(所有行拼接) - total_confidence: 平均置信度 - success: 是否识别成功 - error_message: 错误信息(如果失败) - """ - results: List[OCRResult] - full_text: str - total_confidence: float - success: bool = True - error_message: Optional[str] = None - - def __repr__(self) -> str: - return f"OCRBatchResult(lines={len(self.results)}, confidence={self.total_confidence:.2f})" - - -class ImagePreprocessor: - """ - 图像预处理器 - - 提供常见的图像增强和预处理功能,提高 OCR 识别准确率 - """ - - @staticmethod - def load_image(image_path: str) -> Image.Image: - """ - 加载图像 - - Args: - image_path: 图像文件路径 - - Returns: - PIL Image 对象 - """ - image = Image.open(image_path) - # 转换为 RGB 模式 - if image.mode != 'RGB': - image = image.convert('RGB') - return image - - @staticmethod - def resize_image(image: Image.Image, max_width: int = 2000) -> Image.Image: - """ - 调整图像大小(保持宽高比) - - Args: - image: PIL Image 对象 - max_width: 最大宽度 - - Returns: - 调整后的图像 - """ - if image.width > max_width: - ratio = max_width / image.width - new_height = int(image.height * ratio) - image = image.resize((max_width, new_height), Image.Resampling.LANCZOS) - return image - - @staticmethod - def enhance_contrast(image: Image.Image, factor: float = 1.5) -> Image.Image: - """ - 增强对比度 - - Args: - image: PIL Image 对象 - factor: 增强因子,1.0 表示原始,>1.0 增强,<1.0 减弱 - - Returns: - 处理后的图像 - """ - enhancer = ImageEnhance.Contrast(image) - return enhancer.enhance(factor) - - @staticmethod - def enhance_sharpness(image: Image.Image, factor: float = 1.5) -> Image.Image: - """ - 增强锐度 - - Args: - image: PIL Image 对象 - factor: 锐化因子 - - Returns: - 处理后的图像 - """ - enhancer = ImageEnhance.Sharpness(image) - return enhancer.enhance(factor) - - @staticmethod - def denoise(image: Image.Image) -> Image.Image: - """ - 去噪(使用中值滤波) - - Args: - image: PIL Image 对象 - - Returns: - 处理后的图像 - """ - return image.filter(ImageFilter.MedianFilter(size=3)) - - @staticmethod - def preprocess( - image: Image.Image, - resize: bool = True, - enhance_contrast: bool = True, - enhance_sharpness: bool = True, - denoise: bool = False - ) -> Image.Image: - """ - 综合预处理(根据指定选项) - - Args: - image: PIL Image 对象 - resize: 是否调整大小 - enhance_contrast: 是否增强对比度 - enhance_sharpness: 是否增强锐度 - denoise: 是否去噪 - - Returns: - 处理后的图像 - """ - result = image.copy() - - if resize: - result = ImagePreprocessor.resize_image(result) - - if enhance_contrast: - result = ImagePreprocessor.enhance_contrast(result) - - if enhance_sharpness: - result = ImagePreprocessor.enhance_sharpness(result) - - if denoise: - result = ImagePreprocessor.denoise(result) - - return result - - @staticmethod - def image_to_base64(image: Image.Image, format: str = "JPEG") -> str: - """ - 将 PIL Image 转换为 base64 编码 - - Args: - image: PIL Image 对象 - format: 图像格式 (JPEG/PNG) - - Returns: - base64 编码的字符串 - """ - buffer = io.BytesIO() - image.save(buffer, format=format) - img_bytes = buffer.getvalue() - return base64.b64encode(img_bytes).decode('utf-8') - - -class BaseOCREngine(ABC): - """ - OCR 引擎基类 - - 所有 OCR 实现必须继承此类并实现 recognize 方法 - """ - - def __init__(self, config: Optional[Dict[str, Any]] = None): - """ - 初始化 OCR 引擎 - - Args: - config: OCR 配置字典 - """ - self.config = config or {} - self.preprocessor = ImagePreprocessor() - - @abstractmethod - def recognize( - self, - image, - preprocess: bool = True, - **kwargs - ) -> OCRBatchResult: - """ - 识别图像中的文本 - - Args: - image: 图像(可以是路径或 PIL Image) - preprocess: 是否预处理图像 - **kwargs: 其他参数 - - Returns: - OCRBatchResult: 识别结果 - """ - pass - - def _load_image(self, image) -> Image.Image: - """ - 加载图像(支持多种输入格式) - - Args: - image: 图像(路径或 PIL Image) - - Returns: - PIL Image 对象 - """ - if isinstance(image, str) or isinstance(image, Path): - return self.preprocessor.load_image(str(image)) - elif isinstance(image, Image.Image): - return image - else: - raise ValueError(f"不支持的图像类型: {type(image)}") - - def _calculate_total_confidence(self, results: List[OCRResult]) -> float: - """ - 计算平均置信度 - - Args: - results: OCR 结果列表 - - Returns: - 平均置信度 (0-1) - """ - if not results: - return 0.0 - return sum(r.confidence for r in results) / len(results) - - -class CloudOCREngine(BaseOCREngine): - """ - 云端 OCR 引擎 - - 支持多种云端 OCR 服务: - - 百度 OCR - - 腾讯云 OCR - - 阿里云 OCR - - 自定义 API - """ - - def __init__(self, config: Optional[Dict[str, Any]] = None): - """ - 初始化云端 OCR 引擎 - - Args: - config: 配置字典,支持: - - api_endpoint: API 端点 - - api_key: API 密钥 - - api_secret: API 密钥(部分服务商需要) - - provider: 提供商 (baidu/tencent/aliyun/custom) - - timeout: 超时时间(秒) - """ - super().__init__(config) - - self.api_endpoint = self.config.get('api_endpoint', '') - self.api_key = self.config.get('api_key', '') - self.api_secret = self.config.get('api_secret', '') - self.provider = self.config.get('provider', 'custom') - self.timeout = self.config.get('timeout', 30) - - if not self.api_endpoint: - logger.warning("云端 OCR: api_endpoint 未配置,OCR 功能将不可用") - - def recognize( - self, - image, - preprocess: bool = True, - **kwargs - ) -> OCRBatchResult: - """ - 使用云端 API 识别图像中的文本 - - Args: - image: 图像(路径或 PIL Image) - preprocess: 是否预处理图像 - **kwargs: 其他参数 - - Returns: - OCRBatchResult: 识别结果 - """ - try: - # 加载图像 - pil_image = self._load_image(image) - - # 预处理(如果启用) - if preprocess: - pil_image = self.preprocessor.preprocess(pil_image) - - # 转换为 base64 - img_base64 = self.preprocessor.image_to_base64(pil_image) - - # 根据提供商调用不同的 API - if self.provider == OCRProvider.BAIDU: - return self._baidu_ocr(img_base64) - elif self.provider == OCRProvider.TENCENT: - return self._tencent_ocr(img_base64) - elif self.provider == OCRProvider.ALIYUN: - return self._aliyun_ocr(img_base64) - else: - return self._custom_api_ocr(img_base64) - - except Exception as e: - logger.error(f"云端 OCR 识别失败: {e}", exc_info=True) - return OCRBatchResult( - results=[], - full_text="", - total_confidence=0.0, - success=False, - error_message=str(e) - ) - - def _baidu_ocr(self, img_base64: str) -> OCRBatchResult: - """百度 OCR API""" - try: - # 百度 OCR API 实现 - url = "https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic" - - # 获取 access_token(简化版本,实际应该缓存) - if self.api_key and self.api_secret: - token_url = f"https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={self.api_key}&client_secret={self.api_secret}" - token_resp = requests.get(token_url, timeout=self.timeout) - if token_resp.status_code == 200: - access_token = token_resp.json().get('access_token', '') - url = f"{url}?access_token={access_token}" - - data = { - 'image': img_base64 - } - - response = requests.post(url, data=data, timeout=self.timeout) - result = response.json() - - if 'words_result' in result: - ocr_results = [] - full_lines = [] - - for idx, item in enumerate(result['words_result']): - text = item.get('words', '') - ocr_result = OCRResult( - text=text, - confidence=0.95, # 百度 API 不返回置信度 - line_index=idx - ) - ocr_results.append(ocr_result) - full_lines.append(text) - - full_text = '\n'.join(full_lines) - total_confidence = self._calculate_total_confidence(ocr_results) - - logger.info(f"百度 OCR 识别完成: {len(ocr_results)} 行") - - return OCRBatchResult( - results=ocr_results, - full_text=full_text, - total_confidence=total_confidence, - success=True - ) - else: - error_msg = result.get('error_msg', '未知错误') - return OCRBatchResult( - results=[], - full_text="", - total_confidence=0.0, - success=False, - error_message=error_msg - ) - - except Exception as e: - logger.error(f"百度 OCR 调用失败: {e}") - return OCRBatchResult( - results=[], - full_text="", - total_confidence=0.0, - success=False, - error_message=f"百度 OCR 调用失败: {str(e)}" - ) - - def _tencent_ocr(self, img_base64: str) -> OCRBatchResult: - """腾讯云 OCR API""" - # 腾讯云 OCR 实现占位 - logger.warning("腾讯云 OCR 尚未实现") - return OCRBatchResult( - results=[], - full_text="", - total_confidence=0.0, - success=False, - error_message="腾讯云 OCR 尚未实现" - ) - - def _aliyun_ocr(self, img_base64: str) -> OCRBatchResult: - """阿里云 OCR API""" - # 阿里云 OCR 实现占位 - logger.warning("阿里云 OCR 尚未实现") - return OCRBatchResult( - results=[], - full_text="", - total_confidence=0.0, - success=False, - error_message="阿里云 OCR 尚未实现" - ) - - def _custom_api_ocr(self, img_base64: str) -> OCRBatchResult: - """自定义 API OCR""" - if not self.api_endpoint: - return OCRBatchResult( - results=[], - full_text="", - total_confidence=0.0, - success=False, - error_message="未配置云端 OCR API endpoint" - ) - - try: - headers = { - 'Content-Type': 'application/json', - } - - # 添加 API Key(如果有) - if self.api_key: - headers['Authorization'] = f'Bearer {self.api_key}' - - data = { - 'image': img_base64, - 'format': 'base64' - } - - response = requests.post( - self.api_endpoint, - json=data, - headers=headers, - timeout=self.timeout - ) - - if response.status_code == 200: - result = response.json() - - # 尝试解析常见格式 - if 'text' in result: - # 简单文本格式 - full_text = result['text'] - ocr_results = [OCRResult(text=full_text, confidence=0.9)] - return OCRBatchResult( - results=ocr_results, - full_text=full_text, - total_confidence=0.9, - success=True - ) - elif 'lines' in result: - # 多行格式 - ocr_results = [] - full_lines = [] - for idx, line in enumerate(result['lines']): - text = line.get('text', '') - conf = line.get('confidence', 0.9) - ocr_results.append(OCRResult(text=text, confidence=conf, line_index=idx)) - full_lines.append(text) - - full_text = '\n'.join(full_lines) - total_confidence = self._calculate_total_confidence(ocr_results) - - return OCRBatchResult( - results=ocr_results, - full_text=full_text, - total_confidence=total_confidence, - success=True - ) - else: - return OCRBatchResult( - results=[], - full_text="", - total_confidence=0.0, - success=False, - error_message=f"未知的响应格式: {list(result.keys())}" - ) - else: - return OCRBatchResult( - results=[], - full_text="", - total_confidence=0.0, - success=False, - error_message=f"API 请求失败: HTTP {response.status_code}" - ) - - except Exception as e: - logger.error(f"自定义 API OCR 调用失败: {e}") - return OCRBatchResult( - results=[], - full_text="", - total_confidence=0.0, - success=False, - error_message=f"API 调用失败: {str(e)}" - ) - - -class OCRFactory: - """ - OCR 引擎工厂 - - 根据配置创建对应的 OCR 引擎实例 - """ - - @staticmethod - def create_engine( - mode: str = "cloud", - config: Optional[Dict[str, Any]] = None - ) -> BaseOCREngine: - """ - 创建 OCR 引擎 - - Args: - mode: OCR 模式(当前仅支持 "cloud") - config: 配置字典 - - Returns: - BaseOCREngine: OCR 引擎实例 - - Raises: - ValueError: 不支持的 OCR 模式 - """ - if mode == "cloud": - return CloudOCREngine(config) - else: - # 为了向后兼容,非 cloud 模式也返回云端引擎 - logger.warning(f"OCR 模式 '{mode}' 已弃用,使用云端 OCR") - return CloudOCREngine(config) - - -# 便捷函数 -def recognize_text( - image, - mode: str = "cloud", - preprocess: bool = True, - **kwargs -) -> OCRBatchResult: - """ - 快捷识别文本 - - Args: - image: 图像(路径或 PIL Image) - mode: OCR 模式(仅支持 "cloud") - preprocess: 是否预处理图像 - **kwargs: 其他配置 - - Returns: - OCRBatchResult: 识别结果 - """ - config = kwargs.copy() - engine = OCRFactory.create_engine(mode, config) - return engine.recognize(image, preprocess=preprocess) - - -def preprocess_image( - image_path: str, - output_path: Optional[str] = None, - **kwargs -) -> Image.Image: - """ - 快捷预处理图像 - - Args: - image_path: 输入图像路径 - output_path: 输出图像路径(如果指定,则保存) - **kwargs: 预处理参数 - - Returns: - PIL Image: 处理后的图像 - """ - processed = ImagePreprocessor.preprocess_from_path(image_path, **kwargs) - - if output_path: - processed.save(output_path) - logger.info(f"预处理图像已保存到: {output_path}") - - return processed diff --git a/src/core/processor.py b/src/core/processor.py deleted file mode 100644 index 9610e70..0000000 --- a/src/core/processor.py +++ /dev/null @@ -1,517 +0,0 @@ -""" -处理流程整合模块 - -负责串联 OCR -> AI -> 存储的完整处理流程 -包括错误处理、进度回调、日志记录等功能 -""" - -import logging -from pathlib import Path -from typing import Optional, Callable, Dict, Any, List -from dataclasses import dataclass, field, asdict -from datetime import datetime -import traceback - -from src.core.ocr import recognize_text, OCRBatchResult, OCRFactory -from src.core.ai import classify_text, ClassificationResult, create_classifier_from_config - -# 尝试导入数据库模块(可选) -try: - from src.models.database import Record, get_db, init_database - HAS_DATABASE = True -except ImportError: - HAS_DATABASE = False - logger = logging.getLogger(__name__) - logger.warning("数据库模块不可用,保存功能将不可用") - - -logger = logging.getLogger(__name__) - - -@dataclass -class ProcessResult: - """ - 处理结果数据结构 - - Attributes: - success: 是否处理成功 - image_path: 图片路径 - ocr_result: OCR 识别结果 - ai_result: AI 分类结果 - record_id: 数据库记录 ID(如果保存成功) - error_message: 错误信息(如果失败) - process_time: 处理耗时(秒) - steps_completed: 已完成的步骤列表 - """ - success: bool - image_path: str - ocr_result: Optional[OCRBatchResult] = None - ai_result: Optional[ClassificationResult] = None - record_id: Optional[int] = None - error_message: Optional[str] = None - process_time: float = 0.0 - steps_completed: List[str] = field(default_factory=list) - warnings: List[str] = field(default_factory=list) - - def to_dict(self) -> Dict[str, Any]: - """转换为字典""" - return { - 'success': self.success, - 'image_path': self.image_path, - 'ocr_text': self.ocr_result.full_text if self.ocr_result else None, - 'category': self.ai_result.category.value if self.ai_result else None, - 'title': self.ai_result.title if self.ai_result else None, - 'content': self.ai_result.content if self.ai_result else None, - 'tags': self.ai_result.tags if self.ai_result else [], - 'confidence': self.ai_result.confidence if self.ai_result else None, - 'record_id': self.record_id, - 'error_message': self.error_message, - 'process_time': self.process_time, - 'steps_completed': self.steps_completed, - 'warnings': self.warnings, - } - - -class ProcessCallback: - """ - 处理进度回调类 - - 用于在处理过程中通知 GUI 更新进度 - """ - - def __init__(self): - self.on_start: Optional[Callable] = None - self.on_ocr_start: Optional[Callable] = None - self.on_ocr_complete: Optional[Callable] = None - self.on_ai_start: Optional[Callable] = None - self.on_ai_complete: Optional[Callable] = None - self.on_save_start: Optional[Callable] = None - self.on_save_complete: Optional[Callable] = None - self.on_error: Optional[Callable] = None - self.on_complete: Optional[Callable] = None - self.on_progress: Optional[Callable] = None - - def start(self, message: str = "开始处理"): - """处理开始""" - logger.info(f"处理开始: {message}") - if self.on_start: - self.on_start(message) - - def ocr_start(self, message: str = "开始 OCR 识别"): - """OCR 识别开始""" - logger.info(message) - if self.on_ocr_start: - self.on_ocr_start(message) - - def ocr_complete(self, result: OCRBatchResult): - """OCR 识别完成""" - logger.info(f"OCR 识别完成: {len(result.results)} 行文本, 置信度 {result.total_confidence:.2f}") - if self.on_ocr_complete: - self.on_ocr_complete(result) - - def ai_start(self, message: str = "开始 AI 分类"): - """AI 分类开始""" - logger.info(message) - if self.on_ai_start: - self.on_ai_start(message) - - def ai_complete(self, result: ClassificationResult): - """AI 分类完成""" - logger.info(f"AI 分类完成: {result.category.value}, 置信度 {result.confidence:.2f}") - if self.on_ai_complete: - self.on_ai_complete(result) - - def save_start(self, message: str = "开始保存到数据库"): - """保存开始""" - logger.info(message) - if self.on_save_start: - self.on_save_start(message) - - def save_complete(self, record_id: int): - """保存完成""" - logger.info(f"保存完成: 记录 ID {record_id}") - if self.on_save_complete: - self.on_save_complete(record_id) - - def error(self, message: str, exception: Optional[Exception] = None): - """处理出错""" - logger.error(f"处理错误: {message}", exc_info=exception is not None) - if self.on_error: - self.on_error(message, exception) - - def complete(self, result: ProcessResult): - """处理完成""" - logger.info(f"处理完成: 成功={result.success}, 耗时={result.process_time:.2f}秒") - if self.on_complete: - self.on_complete(result) - - def progress(self, step: str, progress: float, message: str = ""): - """ - 进度更新 - - Args: - step: 当前步骤名称 - progress: 进度百分比 (0-100) - message: 附加信息 - """ - if self.on_progress: - self.on_progress(step, progress, message) - - -class ImageProcessor: - """ - 图片处理器 - - 负责整合 OCR、AI 分类、数据库存储的完整流程 - """ - - def __init__( - self, - ocr_config: Optional[Dict[str, Any]] = None, - ai_config: Optional[Any] = None, - db_path: Optional[str] = None, - callback: Optional[ProcessCallback] = None - ): - """ - 初始化图片处理器 - - Args: - ocr_config: OCR 配置字典 - ai_config: AI 配置对象 - db_path: 数据库路径 - callback: 进度回调对象 - """ - self.ocr_config = ocr_config or {} - self.ai_config = ai_config - self.db_path = db_path - self.callback = callback or ProcessCallback() - - # 初始化数据库 - if db_path and HAS_DATABASE: - init_database(db_path) - - # 创建 AI 分类器(延迟初始化) - self.ai_classifier = None - - def _get_ai_classifier(self): - """获取 AI 分类器(延迟初始化)""" - if self.ai_classifier is None and self.ai_config: - try: - self.ai_classifier = create_classifier_from_config(self.ai_config) - except Exception as e: - logger.warning(f"AI 分类器初始化失败: {e}") - return self.ai_classifier - - def process_image( - self, - image_path: str, - save_to_db: bool = True, - skip_ocr: bool = False, - skip_ai: bool = False, - ocr_text: Optional[str] = None - ) -> ProcessResult: - """ - 处理图片:OCR -> AI 分类 -> 保存到数据库 - - Args: - image_path: 图片文件路径 - save_to_db: 是否保存到数据库 - skip_ocr: 是否跳过 OCR(使用提供的 ocr_text) - skip_ai: 是否跳过 AI 分类 - ocr_text: 直接提供的 OCR 文本(当 skip_ocr=True 时使用) - - Returns: - ProcessResult: 处理结果 - """ - start_time = datetime.now() - steps_completed = [] - warnings = [] - - try: - self.callback.start(f"开始处理图片: {Path(image_path).name}") - - # 步骤 1: OCR 识别 - ocr_result = None - final_ocr_text = ocr_text - - if skip_ocr and ocr_text: - # 使用提供的 OCR 文本 - logger.info("跳过 OCR,使用提供的文本") - final_ocr_text = ocr_text - steps_completed.append("ocr") - elif skip_ocr: - warnings.append("skip_ocr=True 但未提供 ocr_text,OCR 结果将为空") - steps_completed.append("ocr_skipped") - else: - self.callback.ocr_start() - try: - ocr_mode = self.ocr_config.get('mode', 'local') - ocr_lang = self.ocr_config.get('lang', 'ch') - ocr_use_gpu = self.ocr_config.get('use_gpu', False) - - ocr_result = recognize_text( - image=image_path, - mode=ocr_mode, - lang=ocr_lang, - use_gpu=ocr_use_gpu, - preprocess=False # 暂不启用预处理 - ) - - if not ocr_result.success: - warnings.append(f"OCR 识别失败: {ocr_result.error_message}") - elif not ocr_result.full_text.strip(): - warnings.append("OCR 识别结果为空") - else: - final_ocr_text = ocr_result.full_text - steps_completed.append("ocr") - self.callback.ocr_complete(ocr_result) - - except Exception as e: - error_msg = f"OCR 识别异常: {str(e)}" - warnings.append(error_msg) - logger.error(error_msg, exc_info=True) - - # 步骤 2: AI 分类 - ai_result = None - - if skip_ai: - logger.info("跳过 AI 分类") - steps_completed.append("ai_skipped") - elif not final_ocr_text or not final_ocr_text.strip(): - warnings.append("OCR 文本为空,跳过 AI 分类") - steps_completed.append("ai_skipped") - else: - ai_classifier = self._get_ai_classifier() - if ai_classifier is None: - warnings.append("AI 分类器未初始化,跳过 AI 分类") - steps_completed.append("ai_skipped") - else: - self.callback.ai_start() - try: - ai_result = ai_classifier.classify(final_ocr_text) - steps_completed.append("ai") - self.callback.ai_complete(ai_result) - - except Exception as e: - error_msg = f"AI 分类异常: {str(e)}" - warnings.append(error_msg) - logger.error(error_msg, exc_info=True) - - # 步骤 3: 保存到数据库 - record_id = None - - if save_to_db: - if not HAS_DATABASE: - warnings.append("数据库模块不可用,无法保存") - else: - self.callback.save_start() - try: - session = get_db() - - # 创建记录 - record = Record( - image_path=image_path, - ocr_text=final_ocr_text or "", - category=ai_result.category.value if ai_result else "TEXT", - ai_result=ai_result.content if ai_result else None, - tags=ai_result.tags if ai_result else None, - notes=None - ) - - session.add(record) - session.commit() - session.refresh(record) - - record_id = record.id - steps_completed.append("save") - self.callback.save_complete(record_id) - - session.close() - - except Exception as e: - error_msg = f"保存到数据库失败: {str(e)}" - warnings.append(error_msg) - logger.error(error_msg, exc_info=True) - - # 计算处理时间 - process_time = (datetime.now() - start_time).total_seconds() - - # 判断是否成功 - success = len(steps_completed) > 0 - - # 创建结果 - result = ProcessResult( - success=success, - image_path=image_path, - ocr_result=ocr_result, - ai_result=ai_result, - record_id=record_id, - process_time=process_time, - steps_completed=steps_completed, - warnings=warnings - ) - - self.callback.complete(result) - return result - - except Exception as e: - # 捕获未处理的异常 - error_message = f"处理过程发生异常: {str(e)}\n{traceback.format_exc()}" - logger.error(error_message, exc_info=True) - self.callback.error(error_message, e) - - process_time = (datetime.now() - start_time).total_seconds() - - result = ProcessResult( - success=False, - image_path=image_path, - error_message=error_message, - process_time=process_time, - steps_completed=steps_completed, - warnings=warnings - ) - - self.callback.complete(result) - return result - - def batch_process( - self, - image_paths: List[str], - save_to_db: bool = True, - skip_ocr: bool = False, - skip_ai: bool = False - ) -> List[ProcessResult]: - """ - 批量处理图片 - - Args: - image_paths: 图片路径列表 - save_to_db: 是否保存到数据库 - skip_ocr: 是否跳过 OCR - skip_ai: 是否跳过 AI 分类 - - Returns: - 处理结果列表 - """ - results = [] - total = len(image_paths) - - for idx, image_path in enumerate(image_paths): - logger.info(f"批量处理进度: {idx + 1}/{total}") - - # 更新进度 - if self.callback.on_progress: - self.callback.progress( - step=f"处理图片 {idx + 1}/{total}", - progress=(idx / total) * 100, - message=f"当前: {Path(image_path).name}" - ) - - result = self.process_image( - image_path=image_path, - save_to_db=save_to_db, - skip_ocr=skip_ocr, - skip_ai=skip_ai - ) - - results.append(result) - - # 完成进度 - if self.callback.on_progress: - self.callback.progress( - step="批量处理完成", - progress=100, - message=f"共处理 {total} 张图片" - ) - - return results - - -# 便捷函数 -def process_single_image( - image_path: str, - ocr_config: Optional[Dict[str, Any]] = None, - ai_config: Optional[Any] = None, - db_path: Optional[str] = None, - callback: Optional[ProcessCallback] = None -) -> ProcessResult: - """ - 处理单张图片的便捷函数 - - Args: - image_path: 图片路径 - ocr_config: OCR 配置 - ai_config: AI 配置 - db_path: 数据库路径 - callback: 回调对象 - - Returns: - ProcessResult: 处理结果 - """ - processor = ImageProcessor( - ocr_config=ocr_config, - ai_config=ai_config, - db_path=db_path, - callback=callback - ) - - return processor.process_image(image_path) - - -def create_markdown_result(ai_result: ClassificationResult, ocr_text: str = "") -> str: - """ - 创建 Markdown 格式的结果 - - Args: - ai_result: AI 分类结果 - ocr_text: OCR 原始文本 - - Returns: - Markdown 格式的字符串 - """ - if not ai_result: - return f"# 处理结果\n\n## OCR 文本\n\n{ocr_text}" - - category_emoji = { - "TODO": "✅", - "NOTE": "📝", - "IDEA": "💡", - "REF": "📚", - "FUNNY": "😄", - "TEXT": "📄" - } - - emoji = category_emoji.get(ai_result.category.value, "📄") - - markdown = f"""# {emoji} {ai_result.title} - -**分类**: {ai_result.category.value} | **置信度**: {ai_result.confidence:.1%} - ---- - -{ai_result.content} - ---- - -**标签**: {', '.join(ai_result.tags) if ai_result.tags else '无'} -""" - - return markdown - - -def copy_to_clipboard(text: str) -> bool: - """ - 复制文本到剪贴板 - - Args: - text: 要复制的文本 - - Returns: - 是否复制成功 - """ - try: - from src.utils.clipboard import copy_to_clipboard as utils_copy_to_clipboard - return utils_copy_to_clipboard(text) - except Exception as e: - logger.error(f"复制到剪贴板失败: {e}") - return False diff --git a/src/core/screenshot.py b/src/core/screenshot.py new file mode 100644 index 0000000..743f112 --- /dev/null +++ b/src/core/screenshot.py @@ -0,0 +1,234 @@ +""" +核心截图功能模块 +支持全屏、区域、窗口截图 +""" +import os +from pathlib import Path +from datetime import datetime +from typing import Optional, Tuple + +from PyQt6.QtWidgets import QApplication, QWidget, QRubberBand +from PyQt6.QtCore import Qt, QPoint, QRect, QTimer, QEvent +from PyQt6.QtGui import QPixmap, QScreen, QCursor, QPainter, QPen, QColor, QFont + +try: + from PIL import Image +except ImportError: + Image = None + + +class ScreenshotWidget(QWidget): + """截图选择窗口""" + + def __init__(self, screen_pixmap: QPixmap): + super().__init__() + self.screen_pixmap = screen_pixmap + self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.BypassWindowManagerHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.showFullScreen() + + # 状态 + self.selecting = False + self.start_point = QPoint() + self.end_point = QPoint() + self.selection_rect = QRect() + + # 鼠标样式 + self.setCursor(Qt.CrossCursor) + + def paintEvent(self, event): + """绘制选择区域""" + painter = QPainter(self) + painter.drawPixmap(0, 0, self.screen_pixmap) + + # 半透明遮罩 + painter.setBrush(QColor(0, 0, 0, 100)) + painter.drawRect(self.rect()) + + if not self.selection_rect.isEmpty(): + # 清除选中区域的遮罩 + painter.setCompositionMode(QPainter.CompositionMode_Clear) + painter.fillRect(self.selection_rect, QColor(0, 0, 0, 0)) + painter.setCompositionMode(QPainter.CompositionMode_SourceOver) + + # 绘制选中边框 + pen = QPen(QColor(255, 59, 48), 2) + painter.setPen(pen) + painter.drawRect(self.selection_rect) + + # 显示尺寸信息 + info = f"{self.selection_rect.width()} x {self.selection_rect.height()}" + painter.setPen(QColor(255, 255, 255)) + painter.setFont(QFont("Arial", 12)) + painter.drawText( + self.selection_rect.x(), + self.selection_rect.y() - 20, + info + ) + + def mousePressEvent(self, event): + """鼠标按下""" + if event.button() == Qt.LeftButton: + self.selecting = True + self.start_point = event.pos() + self.end_point = event.pos() + self.update_selection() + + def mouseMoveEvent(self, event): + """鼠标移动""" + if self.selecting: + self.end_point = event.pos() + self.update_selection() + self.update() + + def mouseReleaseEvent(self, event): + """鼠标释放""" + if event.button() == Qt.LeftButton and self.selecting: + self.selecting = False + self.update_selection() + # 完成截图 + self.close() + + if not self.selection_rect.isEmpty(): + # 返回选中的区域 + self.accept_selection() + + def keyPressEvent(self, event): + """按键事件""" + if event.key() in (Qt.Key_Escape, Qt.Key_Q): + self.reject_selection() + + def update_selection(self): + """更新选择区域""" + self.selection_rect = QRect( + min(self.start_point.x(), self.end_point.x()), + min(self.start_point.y(), self.end_point.y()), + abs(self.start_point.x() - self.end_point.x()), + abs(self.start_point.y() - self.end_point.y()) + ) + + def accept_selection(self): + """接受选择""" + if hasattr(self, '_callback') and self._callback: + pixmap = self.screen_pixmap.copy(self.selection_rect) + self._callback(pixmap, self.selection_rect) + QApplication.quit() + + def reject_selection(self): + """取消选择""" + self.close() + QApplication.quit() + + def set_callback(self, callback): + """设置回调函数""" + self._callback = callback + + +class Screenshot: + """截图管理器""" + + def __init__(self, save_path: Optional[Path] = None, image_format: str = "png"): + self.save_path = Path(save_path) if save_path else Path.home() / "Pictures" / "Screenshots" + self.save_path.mkdir(parents=True, exist_ok=True) + self.image_format = image_format + self.app: Optional[QApplication] = None + + def capture_fullscreen(self) -> Tuple[QPixmap, str]: + """全屏截图""" + if self.app is None: + self.app = QApplication([]) + self.app.setQuitOnLastWindowClosed(False) + + screen = QApplication.primaryScreen() + pixmap = screen.grabWindow(0) + + return pixmap, self._save_pixmap(pixmap, "fullscreen") + + def capture_primary_screen(self) -> QPixmap: + """截取主屏幕""" + screen = QApplication.primaryScreen() + return screen.grabWindow(0) + + def capture_region(self) -> Tuple[Optional[QPixmap], Optional[str]]: + """区域截图(交互式)""" + if self.app is None: + self.app = QApplication([]) + self.app.setQuitOnLastWindowClosed(False) + + # 获取屏幕 + screen = QApplication.primaryScreen() + screen_pixmap = screen.grabWindow(0) + + # 创建选择窗口 + selector = ScreenshotWidget(screen_pixmap) + + result = {'pixmap': None, 'filepath': None} + + def callback(pixmap, rect): + result['pixmap'] = pixmap + result['filepath'] = self._save_pixmap(pixmap, "region") + + selector.set_callback(callback) + selector.show() + + self.app.exec() + + return result['pixmap'], result['filepath'] + + def _save_pixmap(self, pixmap: QPixmap, prefix: str) -> str: + """保存截图""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{prefix}_{timestamp}.{self.image_format}" + filepath = self.save_path / filename + + # 保存 + pixmap.save(str(filepath), self.image_format.upper()) + + return str(filepath) + + def _get_file_size(self, filepath: str) -> int: + """获取文件大小""" + return os.path.getsize(filepath) + + +def capture_screenshot( + mode: str = "fullscreen", + save_path: Optional[str] = None, + image_format: str = "png" +) -> Tuple[Optional[str], Optional[str]]: + """ + 快捷截图函数 + + Args: + mode: 截图模式 (fullscreen, region) + save_path: 保存路径 + image_format: 图片格式 (png, jpg) + + Returns: + (filepath, error) - 文件路径或错误信息 + """ + try: + screenshot = Screenshot(save_path, image_format) + + if mode == "fullscreen": + pixmap, filepath = screenshot.capture_fullscreen() + return filepath, None + elif mode == "region": + pixmap, filepath = screenshot.capture_region() + return filepath, None + else: + return None, f"不支持的截图模式: {mode}" + + except Exception as e: + return None, f"截图失败: {str(e)}" + + +if __name__ == "__main__": + import sys + + # 测试全屏截图 + filepath, error = capture_screenshot("fullscreen") + if error: + print(f"错误: {error}") + else: + print(f"截图已保存: {filepath}") diff --git a/src/core/storage.py b/src/core/storage.py deleted file mode 100644 index 8b1cfd4..0000000 --- a/src/core/storage.py +++ /dev/null @@ -1,303 +0,0 @@ -""" -存储模块 - 负责数据的持久化和 CRUD 操作 -""" -import json -import os -from datetime import datetime -from typing import List, Dict, Optional, Any -from pathlib import Path - - -class Storage: - """数据存储管理类""" - - def __init__(self, data_dir: str = None): - """ - 初始化存储 - - Args: - data_dir: 数据存储目录,默认为项目根目录下的 data 文件夹 - """ - if data_dir is None: - # 默认使用项目根目录下的 data 文件夹 - project_root = Path(__file__).parent.parent.parent - data_dir = project_root / "data" - - self.data_dir = Path(data_dir) - self.data_file = self.data_dir / "records.json" - - # 确保数据目录存在 - self.data_dir.mkdir(parents=True, exist_ok=True) - - # 初始化数据文件 - self._init_data_file() - - def _init_data_file(self): - """初始化数据文件""" - if not self.data_file.exists(): - self._write_data([]) - - def _read_data(self) -> List[Dict[str, Any]]: - """读取数据文件""" - try: - with open(self.data_file, 'r', encoding='utf-8') as f: - return json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - return [] - - def _write_data(self, data: List[Dict[str, Any]]): - """写入数据文件""" - with open(self.data_file, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - def _generate_id(self) -> str: - """生成唯一 ID""" - return datetime.now().strftime("%Y%m%d%H%M%S%f") - - def create(self, title: str, content: str, category: str = "默认分类", - tags: List[str] = None, metadata: Dict[str, Any] = None) -> Dict[str, Any]: - """ - 创建新记录 - - Args: - title: 标题 - content: 内容 - category: 分类 - tags: 标签列表 - metadata: 额外的元数据 - - Returns: - 创建的记录字典 - """ - records = self._read_data() - - new_record = { - "id": self._generate_id(), - "title": title, - "content": content, - "category": category, - "tags": tags or [], - "metadata": metadata or {}, - "created_at": datetime.now().isoformat(), - "updated_at": datetime.now().isoformat() - } - - records.append(new_record) - self._write_data(records) - - return new_record - - def get_by_id(self, record_id: str) -> Optional[Dict[str, Any]]: - """ - 根据 ID 获取单个记录 - - Args: - record_id: 记录 ID - - Returns: - 记录字典,如果不存在则返回 None - """ - records = self._read_data() - for record in records: - if record["id"] == record_id: - return record - return None - - def get_all(self) -> List[Dict[str, Any]]: - """ - 获取所有记录 - - Returns: - 所有记录的列表 - """ - return self._read_data() - - def update(self, record_id: str, title: str = None, content: str = None, - category: str = None, tags: List[str] = None, - metadata: Dict[str, Any] = None) -> Optional[Dict[str, Any]]: - """ - 更新记录 - - Args: - record_id: 记录 ID - title: 新标题 - content: 新内容 - category: 新分类 - tags: 新标签列表 - metadata: 新元数据 - - Returns: - 更新后的记录字典,如果记录不存在则返回 None - """ - records = self._read_data() - - for i, record in enumerate(records): - if record["id"] == record_id: - # 更新提供的字段 - if title is not None: - record["title"] = title - if content is not None: - record["content"] = content - if category is not None: - record["category"] = category - if tags is not None: - record["tags"] = tags - if metadata is not None: - record["metadata"].update(metadata) - - # 更新时间戳 - record["updated_at"] = datetime.now().isoformat() - - # 保存更新后的数据 - records[i] = record - self._write_data(records) - - return record - - return None - - def delete(self, record_id: str) -> bool: - """ - 删除记录 - - Args: - record_id: 记录 ID - - Returns: - 是否删除成功 - """ - records = self._read_data() - original_length = len(records) - - # 过滤掉要删除的记录 - records = [r for r in records if r["id"] != record_id] - - if len(records) < original_length: - self._write_data(records) - return True - - return False - - def get_by_category(self, category: str) -> List[Dict[str, Any]]: - """ - 按分类获取记录 - - Args: - category: 分类名称 - - Returns: - 该分类下的所有记录 - """ - records = self._read_data() - return [r for r in records if r["category"] == category] - - def get_categories(self) -> List[str]: - """ - 获取所有分类 - - Returns: - 分类列表 - """ - records = self._read_data() - categories = set(r["category"] for r in records) - return sorted(list(categories)) - - def search(self, keyword: str, search_in: List[str] = None) -> List[Dict[str, Any]]: - """ - 搜索记录 - - Args: - keyword: 搜索关键词 - search_in: 搜索字段列表,默认为 ["title", "content", "tags"] - - Returns: - 匹配的记录列表 - """ - if search_in is None: - search_in = ["title", "content", "tags"] - - records = self._read_data() - keyword_lower = keyword.lower() - - results = [] - for record in records: - # 搜索标题 - if "title" in search_in and keyword_lower in record["title"].lower(): - results.append(record) - continue - - # 搜索内容 - if "content" in search_in and keyword_lower in record["content"].lower(): - results.append(record) - continue - - # 搜索标签 - if "tags" in search_in: - for tag in record.get("tags", []): - if keyword_lower in tag.lower(): - results.append(record) - break - - return results - - def get_stats(self) -> Dict[str, Any]: - """ - 获取统计信息 - - Returns: - 包含统计数据的字典 - """ - records = self._read_data() - - total = len(records) - categories = self.get_categories() - category_counts = {cat: 0 for cat in categories} - - for record in records: - category = record["category"] - if category in category_counts: - category_counts[category] += 1 - - return { - "total_records": total, - "total_categories": len(categories), - "categories": category_counts - } - - def export_data(self) -> List[Dict[str, Any]]: - """ - 导出所有数据 - - Returns: - 所有记录的列表 - """ - return self._read_data() - - def import_data(self, data: List[Dict[str, Any]], merge: bool = False) -> int: - """ - 导入数据 - - Args: - data: 要导入的数据列表 - merge: 是否合并(True)还是覆盖(False) - - Returns: - 导入的记录数量 - """ - if not merge: - # 覆盖模式:直接写入新数据 - self._write_data(data) - return len(data) - else: - # 合并模式:将新数据添加到现有数据 - records = self._read_data() - existing_ids = {r["id"] for r in records} - - added_count = 0 - for record in data: - if record["id"] not in existing_ids: - records.append(record) - added_count += 1 - - self._write_data(records) - return added_count diff --git a/src/core/uploader.py b/src/core/uploader.py new file mode 100644 index 0000000..37db103 --- /dev/null +++ b/src/core/uploader.py @@ -0,0 +1,182 @@ +""" +上传功能模块 +支持多种上传服务 +""" +import os +import mimetypes +from pathlib import Path +from typing import Optional, Dict, Any +import requests + + +class UploadResult: + """上传结果""" + def __init__(self, success: bool, url: Optional[str] = None, error: Optional[str] = None): + self.success = success + self.url = url + self.error = error + + +class BaseUploader: + """上传器基类""" + + def __init__(self, config: Dict[str, Any]): + self.config = config + + def upload(self, filepath: str) -> UploadResult: + """上传文件""" + raise NotImplementedError + + def _get_mime_type(self, filepath: str) -> str: + """获取 MIME 类型""" + mime, _ = mimetypes.guess_type(filepath) + return mime or 'image/png' + + def _get_file_size(self, filepath: str) -> int: + """获取文件大小""" + return os.path.getsize(filepath) + + +class CustomUploader(BaseUploader): + """自定义上传器(通用 POST 上传)""" + + def upload(self, filepath: str) -> UploadResult: + endpoint = self.config.get('endpoint', '') + api_key = self.config.get('api_key', '') + + if not endpoint: + return UploadResult(False, error="未配置上传端点") + + try: + with open(filepath, 'rb') as f: + files = { + 'file': ( + Path(filepath).name, + f, + self._get_mime_type(filepath) + ) + } + + headers = {} + if api_key: + headers['Authorization'] = f'Bearer {api_key}' + + response = requests.post( + endpoint, + files=files, + headers=headers, + timeout=30 + ) + + if response.status_code == 200: + # 尝试解析响应 + data = response.json() + url = data.get('url') or data.get('link') or data.get('url') + if url: + return UploadResult(True, url=url) + else: + return UploadResult(False, error="响应中未找到 URL") + else: + return UploadResult( + False, + error=f"上传失败: HTTP {response.status_code}" + ) + + except requests.RequestException as e: + return UploadResult(False, error=f"网络错误: {str(e)}") + except Exception as e: + return UploadResult(False, error=f"上传错误: {str(e)}") + + +class TelegraphUploader(BaseUploader): + """Telegraph 图片上传(免费图床)""" + + API_URL = "https://telegra.ph/upload" + + def upload(self, filepath: str) -> UploadResult: + try: + with open(filepath, 'rb') as f: + files = {'file': (Path(filepath).name, f, self._get_mime_type(filepath))} + response = requests.post(self.API_URL, files=files, timeout=30) + + if response.status_code == 200: + data = response.json() + # Telegraph 返回格式: [{"src": "..."}] + if isinstance(data, list) and len(data) > 0: + src = data[0].get('src', '') + url = f"https://telegra.ph{src}" + return UploadResult(True, url=url) + else: + return UploadResult(False, error="响应格式错误") + else: + return UploadResult(False, error=f"上传失败: HTTP {response.status_code}") + + except Exception as e: + return UploadResult(False, error=f"上传错误: {str(e)}") + + +class ImgurUploader(BaseUploader): + """Imgur 上传器(需要 API Key)""" + + API_URL = "https://api.imgur.com/3/image" + + def upload(self, filepath: str) -> UploadResult: + api_key = self.config.get('api_key', '') + if not api_key: + return UploadResult(False, error="未配置 Imgur API Key") + + try: + with open(filepath, 'rb') as f: + files = {'image': (Path(filepath).name, f, self._get_mime_type(filepath))} + headers = {'Authorization': f'Client-ID {api_key}'} + + response = requests.post(self.API_URL, files=files, headers=headers, timeout=30) + + if response.status_code == 200: + data = response.json() + if data.get('success'): + url = data['data'].get('link') + return UploadResult(True, url=url) + else: + error = data.get('data', {}).get('error', '未知错误') + return UploadResult(False, error=str(error)) + else: + return UploadResult(False, error=f"上传失败: HTTP {response.status_code}") + + except Exception as e: + return UploadResult(False, error=f"上传错误: {str(e)}") + + +class UploaderFactory: + """上传器工厂""" + + UPLOADERS = { + 'custom': CustomUploader, + 'telegraph': TelegraphUploader, + 'imgur': ImgurUploader, + } + + @classmethod + def create(cls, provider: str, config: Dict[str, Any]) -> BaseUploader: + """创建上传器""" + uploader_class = cls.UPLOADERS.get(provider.lower()) + if uploader_class is None: + # 默认使用自定义上传器 + uploader_class = CustomUploader + return uploader_class(config) + + +def upload_file(filepath: str, provider: str, config: Dict[str, Any]) -> UploadResult: + """ + 上传文件 + + Args: + filepath: 文件路径 + provider: 提供商名称 + config: 配置字典 + + Returns: + UploadResult: 上传结果 + """ + uploader = UploaderFactory.create(provider, config) + return uploader.upload(filepath) diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 81dbc39..c2732d5 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -1,599 +1,539 @@ """ -主窗口模块 - -实现应用程序的主窗口,包括侧边栏导航和主内容区域 -集成图片处理功能 +简化的主窗口 +极简 GUI,专注核心功能:截图、上传、浏览 """ +import os +from pathlib import Path +from datetime import datetime +from typing import List, Optional from PyQt6.QtWidgets import ( - QMainWindow, - QWidget, - QHBoxLayout, - QVBoxLayout, - QPushButton, - QStackedWidget, - QLabel, - QFrame, - QScrollArea, - QApplication, - QFileDialog, - QMessageBox + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QListWidget, QListWidgetItem, QLabel, QStackedWidget, + QFileDialog, QMessageBox, QStatusBar, QToolBar, + QStyle, QLineEdit, QComboBox, QDialog, QDialogButtonBox, + QTextEdit, QCheckBox, QSpinBox, QGroupBox, QFormLayout ) -from PyQt6.QtCore import Qt, QSize, pyqtSignal, QThread, QTimer -from PyQt6.QtGui import QIcon, QShortcut, QKeySequence +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QSize +from PyQt6.QtGui import QPixmap, QIcon, QAction, QKeySequence, QShortcut -from src.gui.styles import ThemeStyles -from src.gui.widgets import ( - ScreenshotWidget, - ClipboardMonitor, - ImagePicker, - ImagePreviewWidget, - QuickScreenshotHelper, - ClipboardImagePicker -) -from src.gui.widgets.message_handler import show_info, show_error +from src.config import get_config +from src.core.database import Database, Record, get_db +from src.core.screenshot import Screenshot +from src.core.uploader import upload_file, UploadResult +from src.plugins.ocr import get_ocr_plugin + + +class UploadThread(QThread): + """上传线程""" + finished = pyqtSignal(bool, str, str) # success, url, error + + def __init__(self, filepath: str, provider: str, config: dict): + super().__init__() + self.filepath = filepath + self.provider = provider + self.config = config + + def run(self): + """执行上传""" + try: + result = upload_file(self.filepath, self.provider, self.config) + self.finished(result.success, result.url or '', result.error or '') + except Exception as e: + self.finished(False, '', str(e)) + + +class OCRThread(QThread): + """OCR 线程""" + finished = pyqtSignal(bool, str, str) # success, text, error + + def __init__(self, filepath: str, ocr_plugin): + super().__init__() + self.filepath = filepath + self.ocr_plugin = ocr_plugin + + def run(self): + """执行 OCR""" + success, text, error = self.ocr_plugin.recognize(self.filepath) + self.finished(success, text or '', error or '') + + +class SettingsDialog(QDialog): + """设置对话框""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("设置") + self.setMinimumWidth(500) + self.config = get_config().app + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # 上传设置 + upload_group = QGroupBox("上传设置") + upload_layout = QFormLayout() + + self.provider_combo = QComboBox() + self.provider_combo.addItems(["custom", "telegraph", "imgur"]) + self.provider_combo.setCurrentText(self.config.upload.provider) + + self.endpoint_edit = QLineEdit(self.config.upload.endpoint) + self.endpoint_edit.setPlaceholderText("https://your-server.com/upload") + + self.api_key_edit = QLineEdit(self.config.upload.api_key) + self.api_key_edit.setPlaceholderText("可选的 API Key") + + self.auto_copy_check = QCheckBox("上传后自动复制链接") + self.auto_copy_check.setChecked(self.config.upload.auto_copy) + + upload_layout.addRow("上传方式:", self.provider_combo) + upload_layout.addRow("上传端点:", self.endpoint_edit) + upload_layout.addRow("API Key:", self.api_key_edit) + upload_layout.addRow("", self.auto_copy_check) + upload_group.setLayout(upload_layout) + + # 截图设置 + shot_group = QGroupBox("截图设置") + shot_layout = QFormLayout() + + self.format_combo = QComboBox() + self.format_combo.addItems(["png", "jpg", "webp"]) + self.format_combo.setCurrentText(self.config.screenshot.format) + + self.path_edit = QLineEdit(self.config.screenshot.save_path) + self.path_edit.setPlaceholderText("~/Pictures/Screenshots") + + shot_layout.addRow("保存格式:", self.format_combo) + shot_layout.addRow("保存路径:", self.path_edit) + shot_group.setLayout(shot_layout) + + # OCR 设置 + ocr_group = QGroupBox("OCR 设置(可选)") + ocr_layout = QFormLayout() + + self.ocr_enabled_check = QCheckBox("启用 OCR") + self.ocr_enabled_check.setChecked(self.config.ocr.enabled) + + self.ocr_auto_copy_check = QCheckBox("识别后自动复制文本") + self.ocr_auto_copy_check.setChecked(self.config.ocr.auto_copy) + + ocr_layout.addRow("", self.ocr_enabled_check) + ocr_layout.addRow("", self.ocr_auto_copy_check) + ocr_group.setLayout(ocr_layout) + + layout.addWidget(upload_group) + layout.addWidget(shot_group) + layout.addWidget(ocr_group) + + # 按钮 + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_config(self) -> dict: + """获取配置""" + return { + 'upload': { + 'provider': self.provider_combo.currentText(), + 'endpoint': self.endpoint_edit.text(), + 'api_key': self.api_key_edit.text(), + 'auto_copy': self.auto_copy_check.isChecked(), + }, + 'screenshot': { + 'format': self.format_combo.currentText(), + 'save_path': self.path_edit.text(), + }, + 'ocr': { + 'enabled': self.ocr_enabled_check.isChecked(), + 'auto_copy': self.ocr_auto_copy_check.isChecked(), + } + } class MainWindow(QMainWindow): - """主窗口类""" - - # 信号:图片加载完成 - image_loaded = pyqtSignal(str) + """主窗口""" def __init__(self): - """初始化主窗口""" super().__init__() - - self.setWindowTitle("CutThenThink - 智能截图管理") - self.setMinimumSize(1000, 700) - self.resize(1200, 800) - - # 图片处理组件 - self.screenshot_widget = None - self.clipboard_monitor = None - self.current_image_path = None - - # 初始化 UI - self._init_ui() - self._apply_styles() - self._init_shortcuts() - - # 初始化图片处理组件 - self._init_image_components() - - def _init_ui(self): - """初始化用户界面""" - # 创建中央部件 - central_widget = QWidget() - self.setCentralWidget(central_widget) - - # 主布局 - main_layout = QHBoxLayout(central_widget) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - - # 创建侧边栏 - self._create_sidebar(main_layout) - - # 创建主内容区域 - self._create_content_area(main_layout) - - def _create_sidebar(self, parent_layout): - """ - 创建侧边栏 - - Args: - parent_layout: 父布局 - """ - # 侧边栏容器 - sidebar = QWidget() - sidebar.setObjectName("sidebar") - sidebar.setFixedWidth(240) - - # 侧边栏布局 - sidebar_layout = QVBoxLayout(sidebar) - sidebar_layout.setContentsMargins(8, 16, 8, 16) - sidebar_layout.setSpacing(4) - - # 应用标题 - app_title = QLabel("CutThenThink") - app_title.setStyleSheet(""" - QLabel { - color: #8B6914; - font-size: 20px; - font-weight: 700; - padding: 8px; - } - """) - sidebar_layout.addWidget(app_title) - - # 添加分隔线 - separator1 = self._create_separator() - sidebar_layout.addWidget(separator1) - - # 导航按钮组 - self.nav_buttons = {} - nav_items = [ - ("screenshot", "📷 截图处理", "screenshot"), - ("browse", "📁 分类浏览", "browse"), - ("upload", "☁️ 批量上传", "upload"), - ("settings", "⚙️ 设置", "settings"), - ] - - for nav_id, text, _icon_name in nav_items: - button = NavigationButton(text) - button.clicked.connect(lambda checked, nid=nav_id: self._on_nav_clicked(nid)) - sidebar_layout.addWidget(button) - self.nav_buttons[nav_id] = button - - # 添加弹性空间 - sidebar_layout.addStretch() - - # 底部信息 - version_label = QLabel("v0.1.0") - version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - version_label.setStyleSheet(""" - QLabel { - color: #999999; - font-size: 11px; - padding: 8px; - } - """) - sidebar_layout.addWidget(version_label) - - # 添加到主布局 - parent_layout.addWidget(sidebar) - - # 设置默认选中的导航项 - self.nav_buttons["screenshot"].setChecked(True) - self.current_nav = "screenshot" - - def _create_content_area(self, parent_layout): - """ - 创建主内容区域 - - Args: - parent_layout: 父布局 - """ - # 内容区域容器 - content_area = QWidget() - content_area.setObjectName("contentArea") - - # 内容区域布局 - content_layout = QVBoxLayout(content_area) - content_layout.setContentsMargins(24, 16, 24, 16) - content_layout.setSpacing(16) - - # 创建堆栈部件用于切换页面 - self.content_stack = QStackedWidget() - self.content_stack.setObjectName("contentStack") - - # 创建各个页面 - self._create_pages() - - content_layout.addWidget(self.content_stack) - - # 添加到主布局 - parent_layout.addWidget(content_area) - - def _create_pages(self): - """创建各个页面""" - # 截图处理页面 - screenshot_page = self._create_screenshot_page() - self.content_stack.addWidget(screenshot_page) - - # 分类浏览页面 - browse_page = self._create_browse_page() - self.content_stack.addWidget(browse_page) - - # 批量上传页面 - upload_page = self._create_upload_page() - self.content_stack.addWidget(upload_page) - - # 设置页面 - settings_page = self._create_settings_page() - self.content_stack.addWidget(settings_page) - - def _create_screenshot_page(self) -> QWidget: - """创建截图处理页面""" - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(16) - - # 页面标题 - title = QLabel("📷 截图处理") - title.setObjectName("pageTitle") - layout.addWidget(title) - - # 快捷操作按钮区域 - actions_card = self._create_card("") - actions_layout = QVBoxLayout(actions_card) - actions_layout.setSpacing(12) - - actions_title = QLabel("快捷操作") - actions_title.setObjectName("sectionTitle") - actions_layout.addWidget(actions_title) - - # 新建截图按钮 - self.new_screenshot_btn = QPushButton("📷 新建截图") - self.new_screenshot_btn.setObjectName("primaryButton") - self.new_screenshot_btn.setMinimumHeight(44) - self.new_screenshot_btn.setToolTip("快捷键: Ctrl+Shift+A") - self.new_screenshot_btn.clicked.connect(self._on_new_screenshot) - actions_layout.addWidget(self.new_screenshot_btn) - - # 导入图片按钮 - self.import_image_btn = QPushButton("📂 导入图片") - self.import_image_btn.setMinimumHeight(44) - self.import_image_btn.clicked.connect(self._on_import_image) - actions_layout.addWidget(self.import_image_btn) - - # 粘贴剪贴板图片按钮 - self.paste_btn = QPushButton("📋 粘贴剪贴板图片") - self.paste_btn.setMinimumHeight(44) - self.paste_btn.setToolTip("快捷键: Ctrl+Shift+V") - self.paste_btn.clicked.connect(self._on_paste_clipboard) - actions_layout.addWidget(self.paste_btn) - - layout.addWidget(actions_card) - - # 图片预览区域 - preview_card = self._create_card("") - preview_layout = QVBoxLayout(preview_card) - preview_layout.setContentsMargins(16, 16, 16, 16) - preview_layout.setSpacing(12) - - preview_title = QLabel("图片预览") - preview_title.setObjectName("sectionTitle") - preview_layout.addWidget(preview_title) - - # 创建图片预览组件 - self.image_preview = ImagePreviewWidget() - preview_layout.addWidget(self.image_preview) - - layout.addWidget(preview_card, 1) - - return page - - def _create_browse_page(self) -> QWidget: - """创建分类浏览页面""" - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(16) - - # 页面标题 - title = QLabel("📁 分类浏览") - title.setObjectName("pageTitle") - layout.addWidget(title) - - # 内容卡片 - content_card = self._create_card(""" -

浏览截图

-

这里将显示您的所有截图和分类。

-

支持的浏览方式:

- - """) - layout.addWidget(content_card) - - # 添加弹性空间 - layout.addStretch() - - return page - - def _create_upload_page(self) -> QWidget: - """创建批量上传页面""" - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(16) - - # 页面标题 - title = QLabel("☁️ 批量上传") - title.setObjectName("pageTitle") - layout.addWidget(title) - - # 内容卡片 - content_card = self._create_card(""" -

云存储上传

-

这里将管理您的云存储上传任务。

-

功能包括:

- - """) - layout.addWidget(content_card) - - # 添加弹性空间 - layout.addStretch() - - return page - - def _create_settings_page(self) -> QWidget: - """创建设置页面""" - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(16) - - # 页面标题 - title = QLabel("⚙️ 设置") - title.setObjectName("pageTitle") - layout.addWidget(title) - - # 使用滚动区域以支持大量设置项 - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.NoFrame) - - scroll_content = QWidget() - scroll_layout = QVBoxLayout(scroll_content) - scroll_layout.setSpacing(16) - - # AI 配置卡片 - ai_card = self._create_card(""" -

🤖 AI 配置

-

配置您的 AI 服务提供商和 API 设置。

-

支持:OpenAI、Anthropic、Azure

- """) - scroll_layout.addWidget(ai_card) - - # OCR 配置卡片 - ocr_card = self._create_card(""" -

🔍 OCR 配置

-

配置云端 OCR 服务。

-

支持:百度 OCR、腾讯云 OCR、阿里云 OCR、自定义 API

- """) - scroll_layout.addWidget(ocr_card) - - # 云存储配置卡片 - cloud_card = self._create_card(""" -

☁️ 云存储配置

-

配置云存储服务用于同步。

-

支持:S3、OSS、COS、MinIO

- """) - scroll_layout.addWidget(cloud_card) - - # 界面配置卡片 - ui_card = self._create_card(""" -

🎨 界面配置

-

自定义应用程序外观和行为。

-

主题、语言、快捷键等

- """) - scroll_layout.addWidget(ui_card) - - # 添加弹性空间 - scroll_layout.addStretch() - - scroll.setWidget(scroll_content) - layout.addWidget(scroll) - - return page - - def _create_card(self, content_html: str) -> QWidget: - """ - 创建卡片部件 - - Args: - content_html: 卡片内容的 HTML - - Returns: - 卡片部件 - """ - card = QWidget() - card.setObjectName("card") - - layout = QVBoxLayout(card) - layout.setContentsMargins(0, 0, 0, 0) - - label = QLabel(content_html) - label.setWordWrap(True) - label.setTextFormat(Qt.TextFormat.RichText) - label.setStyleSheet(""" - QLabel { - color: #2C2C2C; - font-size: 14px; - line-height: 1.6; - } - QLabel h2 { - color: #8B6914; - font-size: 20px; - font-weight: 600; - margin-bottom: 8px; - } - QLabel h3 { - color: #8B6914; - font-size: 16px; - font-weight: 600; - margin-bottom: 6px; - } - QLabel p { - margin: 4px 0; - } - QLabel ul { - margin: 8px 0; - padding-left: 20px; - } - QLabel li { - margin: 4px 0; - } - """) - layout.addWidget(label) - - return card - - def _create_separator(self) -> QFrame: - """创建分隔线""" - separator = QFrame() - separator.setObjectName("navSeparator") - return separator - - def _on_nav_clicked(self, nav_id: str): - """ - 导航按钮点击处理 - - Args: - nav_id: 导航项 ID - """ - # 取消当前选中的按钮 - if self.current_nav in self.nav_buttons: - self.nav_buttons[self.current_nav].setChecked(False) - - # 选中新按钮 - self.nav_buttons[nav_id].setChecked(True) - self.current_nav = nav_id - - # 切换页面 - nav_order = ["screenshot", "browse", "upload", "settings"] - if nav_id in nav_order: - index = nav_order.index(nav_id) - self.content_stack.setCurrentIndex(index) - - def _apply_styles(self): - """应用样式表""" - ThemeStyles.apply_style(self) - - def _init_shortcuts(self): - """初始化全局快捷键""" - # 截图快捷键 Ctrl+Shift+A - screenshot_shortcut = QShortcut(QKeySequence("Ctrl+Shift+A"), self) - screenshot_shortcut.activated.connect(self._on_new_screenshot) - - # 粘贴剪贴板快捷键 Ctrl+Shift+V - paste_shortcut = QShortcut(QKeySequence("Ctrl+Shift+V"), self) - paste_shortcut.activated.connect(self._on_paste_clipboard) - - # 导入图片快捷键 Ctrl+Shift+O - import_shortcut = QShortcut(QKeySequence("Ctrl+Shift+O"), self) - import_shortcut.activated.connect(self._on_import_image) - - def _init_image_components(self): - """初始化图片处理组件""" - # 创建截图组件 - self.screenshot_widget = ScreenshotWidget(self) - self.screenshot_widget.screenshot_saved.connect(self._on_screenshot_saved) - - # 注册到全局助手 - QuickScreenshotHelper.set_screenshot_widget(self.screenshot_widget) - - # 创建剪贴板监听器 - self.clipboard_monitor = ClipboardMonitor(self) - self.clipboard_monitor.image_detected.connect(self._on_clipboard_image_detected) - - # ========== 图片处理方法 ========== - - def _on_new_screenshot(self): - """新建截图""" - if self.screenshot_widget: - self.screenshot_widget.take_screenshot() - - def _on_screenshot_saved(self, filepath: str): - """ - 截图保存完成回调 - - Args: - filepath: 保存的文件路径 - """ - self.current_image_path = filepath - # 加载到预览组件 - self.image_preview.load_image(filepath) - show_info(self, "截图完成", f"截图已保存到:\n{filepath}") - self.image_loaded.emit(filepath) - - def _on_import_image(self): - """导入图片""" - filepath, _ = QFileDialog.getOpenFileName( - self, - "选择图片", - "", - "图片文件 (*.png *.jpg *.jpeg *.bmp *.gif *.webp);;所有文件 (*.*)" + self.config = get_config() + self.db = get_db() + self.screenshot = Screenshot( + self.config.app.screenshot.save_path, + self.config.app.screenshot.format ) + self.ocr_plugin = get_ocr_plugin() + self.current_records: List[Record] = [] + self.upload_thread: Optional[UploadThread] = None + self.ocr_thread: Optional[OCRThread] = None + self._setup_ui() + self._setup_shortcuts() + self._load_records() - if filepath: - self.current_image_path = filepath - self.image_preview.load_image(filepath) - show_info(self, "导入成功", f"图片已导入:\n{filepath}") - self.image_loaded.emit(filepath) + def _setup_ui(self): + """设置 UI""" + self.setWindowTitle("CutThenThink") + self.setMinimumSize(900, 600) - def _on_paste_clipboard(self): - """粘贴剪贴板图片""" - clipboard = QApplication.clipboard() - pixmap = clipboard.pixmap() + # 创建中心部件 + central = QWidget() + self.setCentralWidget(central) + layout = QHBoxLayout(central) - if pixmap.isNull(): - show_error(self, "错误", "剪贴板中没有图片") + # 侧边栏 + sidebar = self._create_sidebar() + layout.addWidget(sidebar, 1) + + # 主内容区 + self.content_stack = QStackedWidget() + + # 记录列表页 + self.records_page = self._create_records_page() + self.content_stack.addWidget(self.records_page) + + layout.addWidget(self.content_stack, 3) + + # 状态栏 + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("就绪") + + # 工具栏 + toolbar = QToolBar("主工具栏") + toolbar.setMovable(False) + self.addToolBar(toolbar) + + # 截图按钮 + capture_action = QAction("截全屏", self) + capture_action.setShortcut(QKeySequence(self.config.app.hotkeys.capture)) + capture_action.triggered.connect(self.capture_fullscreen) + toolbar.addAction(capture_action) + + # 区域截图按钮 + region_action = QAction("区域截图", self) + region_action.setShortcut(QKeySequence(self.config.app.hotkeys.region)) + region_action.triggered.connect(self.capture_region) + toolbar.addAction(region_action) + + # 上传按钮 + upload_action = QAction("上传最后截图", self) + upload_action.setShortcut(QKeySequence(self.config.app.hotkeys.upload)) + upload_action.triggered.connect(self.upload_last) + toolbar.addAction(upload_action) + + # 设置按钮 + settings_action = QAction("设置", self) + settings_action.triggered.connect(self.show_settings) + toolbar.addAction(settings_action) + + def _create_sidebar(self) -> QWidget: + """创建侧边栏""" + sidebar = QWidget() + layout = QVBoxLayout(sidebar) + + # 全部记录 + all_btn = QPushButton("全部记录") + all_btn.clicked.connect(lambda: self._filter_records(None)) + layout.addWidget(all_btn) + + layout.addStretch() + + return sidebar + + def _create_records_page(self) -> QWidget: + """创建记录列表页""" + page = QWidget() + layout = QVBoxLayout(page) + + # 记录列表 + self.records_list = QListWidget() + self.records_list.setIconSize(QSize(48, 48)) + self.records_list.itemDoubleClicked.connect(self._open_record_detail) + layout.addWidget(self.records_list) + + # 操作按钮 + btn_layout = QHBoxLayout() + + refresh_btn = QPushButton("刷新") + refresh_btn.clicked.connect(self._load_records) + btn_layout.addWidget(refresh_btn) + + upload_btn = QPushButton("上传选中") + upload_btn.clicked.connect(self._upload_selected) + btn_layout.addWidget(upload_btn) + + delete_btn = QPushButton("删除") + delete_btn.clicked.connect(self._delete_selected) + btn_layout.addWidget(delete_btn) + + layout.addLayout(btn_layout) + + return page + + def _setup_shortcuts(self): + """设置快捷键""" + # ESC 退出 + esc_shortcut = QShortcut(QKeySequence(Qt.Key_Escape), self) + esc_shortcut.activated.connect(self.close) + + def _load_records(self, category: Optional[str] = None): + """加载记录""" + self.current_records = self.db.get_all(category=category) + self._update_records_list() + + def _update_records_list(self): + """更新记录列表""" + self.records_list.clear() + + for record in self.current_records: + item = QListWidgetItem(record.filename) + # 设置图标(缩略图) + if Path(record.filepath).exists(): + pixmap = QPixmap(record.filepath).scaled( + 48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + item.setIcon(QIcon(pixmap)) + + # 添加上传状态标记 + if record.upload_url: + item.setText(f"✓ {record.filename}") + else: + item.setText(f" {record.filename}") + + item.setData(Qt.UserRole, record) + self.records_list.addItem(item) + + self.status_bar.showMessage(f"共 {len(self.current_records)} 条记录") + + def _filter_records(self, category: Optional[str]): + """筛选记录""" + self._load_records(category) + + def capture_fullscreen(self): + """全屏截图""" + filepath, error = self.screenshot.capture_fullscreen() + self._handle_screenshot_result(filepath, error) + + def capture_region(self): + """区域截图""" + pixmap, filepath = self.screenshot.capture_region() + if pixmap and filepath: + self._handle_screenshot_result(filepath, None) + else: + self.status_bar.showMessage("截图已取消") + + def _handle_screenshot_result(self, filepath: Optional[str], error: Optional[str]): + """处理截图结果""" + if error: + QMessageBox.warning(self, "截图失败", error) return - # 保存剪贴板图片 - from datetime import datetime - from pathlib import Path - import tempfile + if not filepath: + return - temp_dir = Path(tempfile.gettempdir()) / "cutthenthink" / "clipboard" - temp_dir.mkdir(parents=True, exist_ok=True) + self.status_bar.showMessage(f"截图已保存: {filepath}") - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filepath = temp_dir / f"clipboard_{timestamp}.png" + # 保存到数据库 + file_size = os.path.getsize(filepath) + record = Record( + id=0, # 临时 ID + filename=Path(filepath).name, + filepath=filepath, + file_size=file_size + ) + record_id = self.db.add(record) - if pixmap.save(str(filepath)): - self.current_image_path = filepath - self.image_preview.load_image(filepath) - show_info(self, "粘贴成功", f"剪贴板图片已保存:\n{filepath}") - self.image_loaded.emit(filepath) + # 如果启用了 OCR,执行识别 + if self.config.app.ocr.enabled and self.ocr_plugin.is_available(): + self._run_ocr(filepath, record_id) else: - show_error(self, "错误", "保存剪贴板图片失败") + self._load_records() - def _on_clipboard_image_detected(self, filepath: str): - """ - 剪贴板图片检测回调 + def _run_ocr(self, filepath: str, record_id: int): + """运行 OCR""" + self.status_bar.showMessage("正在识别文字...") - Args: - filepath: 保存的图片路径 - """ - # 可选:自动加载剪贴板图片或显示通知 - pass + self.ocr_thread = OCRThread(filepath, self.ocr_plugin) + self.ocr_thread.finished.connect( + lambda success, text, error: self._handle_ocr_result(record_id, success, text, error) + ) + self.ocr_thread.start() + def _handle_ocr_result(self, record_id: int, success: bool, text: str, error: str): + """处理 OCR 结果""" + if success: + self.db.update(record_id, ocr_text=text) + self.status_bar.showMessage("OCR 识别完成") -class NavigationButton(QPushButton): - """导航按钮类""" + if self.config.app.ocr.auto_copy: + QApplication.clipboard().setText(text) + self.status_bar.showMessage("已复制 OCR 文本") + else: + self.status_bar.showMessage(f"OCR 失败: {error}") - def __init__(self, text: str, icon_path: str = None, parent=None): - """ - 初始化导航按钮 + self._load_records() - Args: - text: 按钮文字 - icon_path: 图标路径(可选) - parent: 父部件 - """ - super().__init__(text, parent) + def _upload_selected(self): + """上传选中的记录""" + current_item = self.records_list.currentItem() + if not current_item: + return - self.setObjectName("navButton") - self.setCheckable(True) - self.setMinimumHeight(44) + record = current_item.data(Qt.UserRole) + self._upload_record(record) - # 如果有图标,加载图标 - if icon_path: - self.setIcon(QIcon(icon_path)) - self.setIconSize(QSize(20, 20)) + def _upload_record(self, record: Record): + """上传单条记录""" + self.status_bar.showMessage(f"正在上传: {record.filename}") + + self.upload_thread = UploadThread( + record.filepath, + self.config.app.upload.provider, + { + 'endpoint': self.config.app.upload.endpoint, + 'api_key': self.config.app.upload.api_key, + } + ) + self.upload_thread.finished.connect( + lambda success, url, error: self._handle_upload_result(record.id, success, url, error) + ) + self.upload_thread.start() + + def upload_last(self): + """上传最后的截图""" + if not self.current_records: + QMessageBox.info(self, "提示", "没有可上传的记录") + return + + # 上传最新的未上传记录 + for record in self.current_records: + if not record.upload_url: + self._upload_record(record) + break + + def _handle_upload_result(self, record_id: int, success: bool, url: str, error: str): + """处理上传结果""" + if success: + self.db.update(record_id, upload_url=url, uploaded_at=datetime.now().isoformat()) + self.status_bar.showMessage(f"上传成功: {url}") + + if self.config.app.upload.auto_copy: + QApplication.clipboard().setText(url) + self.status_bar.showMessage("已复制上传链接") + else: + QMessageBox.critical(self, "上传失败", error) + self.status_bar.showMessage("上传失败") + + self._load_records() + + def _delete_selected(self): + """删除选中的记录""" + current_item = self.records_list.currentItem() + if not current_item: + return + + record = current_item.data(Qt.UserRole) + + reply = QMessageBox.question( + self, "确认删除", + f"确定要删除 \"{record.filename}\" 吗?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.db.delete(record.id) + self._load_records() + + def _open_record_detail(self, item: QListWidgetItem): + """打开记录详情""" + record = item.data(Qt.UserRole) + + dialog = QDialog(self) + dialog.setWindowTitle(record.filename) + dialog.setMinimumSize(500, 400) + layout = QVBoxLayout(dialog) + + # 图片预览 + if Path(record.filepath).exists(): + pixmap = QPixmap(record.filepath) + label = QLabel() + label.setPixmap(pixmap.scaled( + 450, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation + )) + layout.addWidget(label) + + # OCR 文本 + if record.ocr_text: + text_edit = QTextEdit() + text_edit.setPlainText(record.ocr_text) + text_edit.setReadOnly(True) + layout.addWidget(QLabel("识别文字:")) + layout.addWidget(text_edit) + + # 上传链接 + if record.upload_url: + url_layout = QHBoxLayout() + url_edit = QLineEdit(record.upload_url) + url_edit.setReadOnly(True) + copy_btn = QPushButton("复制") + copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(record.upload_url)) + url_layout.addWidget(url_edit) + url_layout.addWidget(copy_btn) + layout.addWidget(QLabel("上传链接:")) + layout.addLayout(url_layout) + + dialog.exec() + + def show_settings(self): + """显示设置对话框""" + dialog = SettingsDialog(self) + if dialog.exec() == QDialog.Accepted: + config_data = dialog.get_config() + + # 更新配置 + self.config.app.upload.provider = config_data['upload']['provider'] + self.config.app.upload.endpoint = config_data['upload']['endpoint'] + self.config.app.upload.api_key = config_data['upload']['api_key'] + self.config.app.upload.auto_copy = config_data['upload']['auto_copy'] + + self.config.app.screenshot.format = config_data['screenshot']['format'] + self.config.app.screenshot.save_path = config_data['screenshot']['save_path'] + + self.config.app.ocr.enabled = config_data['ocr']['enabled'] + self.config.app.ocr.auto_copy = config_data['ocr']['auto_copy'] + + # 保存配置 + self.config.save() + + # 更新截图器 + self.screenshot = Screenshot( + self.config.app.screenshot.save_path, + self.config.app.screenshot.format + ) + + QMessageBox.information(self, "设置", "设置已保存,部分设置需要重启生效") + + def closeEvent(self, event): + """关闭事件""" + self.db.close() + event.accept() def main(): - """ - 应用程序入口 - - 初始化并显示主窗口 - """ + """主函数""" + import sys app = QApplication(sys.argv) + app.setStyle("Fusion") window = MainWindow() window.show() sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/src/gui/styles/__init__.py b/src/gui/styles/__init__.py deleted file mode 100644 index eccd440..0000000 --- a/src/gui/styles/__init__.py +++ /dev/null @@ -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', - ]) diff --git a/src/gui/styles/browse_style.py b/src/gui/styles/browse_style.py deleted file mode 100644 index 67a3deb..0000000 --- a/src/gui/styles/browse_style.py +++ /dev/null @@ -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) diff --git a/src/gui/styles/colors.py b/src/gui/styles/colors.py deleted file mode 100644 index f48db98..0000000 --- a/src/gui/styles/colors.py +++ /dev/null @@ -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") diff --git a/src/gui/styles/theme.py b/src/gui/styles/theme.py deleted file mode 100644 index b6513d5..0000000 --- a/src/gui/styles/theme.py +++ /dev/null @@ -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()) diff --git a/src/gui/widgets/__init__.py b/src/gui/widgets/__init__.py deleted file mode 100644 index 6251058..0000000 --- a/src/gui/widgets/__init__.py +++ /dev/null @@ -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', -] diff --git a/src/gui/widgets/browse_view.py b/src/gui/widgets/browse_view.py deleted file mode 100644 index 79f88e3..0000000 --- a/src/gui/widgets/browse_view.py +++ /dev/null @@ -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() diff --git a/src/gui/widgets/clipboard_monitor.py b/src/gui/widgets/clipboard_monitor.py deleted file mode 100644 index e45267c..0000000 --- a/src/gui/widgets/clipboard_monitor.py +++ /dev/null @@ -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() diff --git a/src/gui/widgets/image_picker.py b/src/gui/widgets/image_picker.py deleted file mode 100644 index 99ef3f8..0000000 --- a/src/gui/widgets/image_picker.py +++ /dev/null @@ -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 diff --git a/src/gui/widgets/image_preview_widget.py b/src/gui/widgets/image_preview_widget.py deleted file mode 100644 index c93c245..0000000 --- a/src/gui/widgets/image_preview_widget.py +++ /dev/null @@ -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(""" -
-

🖼️

-

暂无图片

-

- 请选择或拖入图片 -

-
- """) - - 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) diff --git a/src/gui/widgets/message_handler.py b/src/gui/widgets/message_handler.py deleted file mode 100644 index c3f5637..0000000 --- a/src/gui/widgets/message_handler.py +++ /dev/null @@ -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("<>", 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) diff --git a/src/gui/widgets/record_card.py b/src/gui/widgets/record_card.py deleted file mode 100644 index ad3ff27..0000000 --- a/src/gui/widgets/record_card.py +++ /dev/null @@ -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() diff --git a/src/gui/widgets/record_detail_dialog.py b/src/gui/widgets/record_detail_dialog.py deleted file mode 100644 index defdcfb..0000000 --- a/src/gui/widgets/record_detail_dialog.py +++ /dev/null @@ -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() diff --git a/src/gui/widgets/result_widget.py b/src/gui/widgets/result_widget.py deleted file mode 100644 index 22d6f31..0000000 --- a/src/gui/widgets/result_widget.py +++ /dev/null @@ -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() diff --git a/src/gui/widgets/screenshot_widget.py b/src/gui/widgets/screenshot_widget.py deleted file mode 100644 index 1f9d3b7..0000000 --- a/src/gui/widgets/screenshot_widget.py +++ /dev/null @@ -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() diff --git a/src/main.py b/src/main.py index 7e497aa..2207f37 100644 --- a/src/main.py +++ b/src/main.py @@ -1,18 +1,24 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -CutThenThink 应用入口 +CutThenThink - 极简截图上传工具 -截图 → OCR解析 → AI理解并分类 → 形成备注和执行计划 +截图 → 上传 → 分类浏览 + +核心功能: +- 截图(全屏/区域) +- 上传到云端 +- 历史记录管理 +- 可选 OCR 文字识别 """ import sys import os + def setup_path(): """设置Python路径,兼容开发和打包环境""" if getattr(sys, 'frozen', False): # PyInstaller打包后的环境 - # 在打包环境中,src目录会被解压到sys._MEIPASS base_path = sys._MEIPASS src_path = os.path.join(base_path, 'src') if os.path.exists(src_path): @@ -24,9 +30,22 @@ def setup_path(): current_dir = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, current_dir) + setup_path() -from src.gui.main_window import main + +def main(): + """应用入口""" + from src.gui.main_window import MainWindow + from PyQt6.QtWidgets import QApplication + + app = QApplication(sys.argv) + app.setStyle("Fusion") + + window = MainWindow() + window.show() + + sys.exit(app.exec()) if __name__ == "__main__": diff --git a/src/models/__init__.py b/src/models/__init__.py deleted file mode 100644 index 478cb30..0000000 --- a/src/models/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -数据模型 -""" - -from src.models.database import ( - BaseModel, - Record, - RecordCategory, - DatabaseManager, - db_manager, - init_database, - get_db, -) - -__all__ = [ - 'BaseModel', - 'Record', - 'RecordCategory', - 'DatabaseManager', - 'db_manager', - 'init_database', - 'get_db', -] diff --git a/src/models/database.py b/src/models/database.py deleted file mode 100644 index f50a153..0000000 --- a/src/models/database.py +++ /dev/null @@ -1,197 +0,0 @@ -""" -数据库模型定义 - -使用 SQLAlchemy ORM 定义 Record 模型 -""" - -from datetime import datetime -from typing import Optional -from sqlalchemy import Column, Integer, String, Text, DateTime, JSON -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, DeclarativeBase - -# 使用新版SQLAlchemy的DeclarativeBase -class BaseModel(DeclarativeBase): - pass - - -class RecordCategory: - """记录分类常量""" - TODO = "TODO" # 待办事项 - NOTE = "NOTE" # 笔记 - IDEA = "IDEA" # 灵感 - REF = "REF" # 参考资料 - FUNNY = "FUNNY" # 搞笑文案 - TEXT = "TEXT" # 纯文本 - - @classmethod - def all(cls): - """获取所有分类类型""" - return [cls.TODO, cls.NOTE, cls.IDEA, cls.REF, cls.FUNNY, cls.TEXT] - - @classmethod - def is_valid(cls, category: str) -> bool: - """验证分类是否有效""" - return category in cls.all() - - -class Record(BaseModel): - """记录模型 - 存储图片识别和AI处理结果""" - - __tablename__ = 'records' - - # 主键 - id = Column(Integer, primary_key=True, autoincrement=True, comment='记录ID') - - # 图片路径 - image_path = Column(String(512), nullable=False, unique=True, index=True, comment='图片存储路径') - - # OCR识别结果 - ocr_text = Column(Text, nullable=True, comment='OCR识别的文本内容') - - # 分类类型 - category = Column( - String(20), - nullable=False, - default=RecordCategory.NOTE, - index=True, - comment='记录分类' - ) - - # AI生成的Markdown内容 - ai_result = Column(Text, nullable=True, comment='AI处理生成的Markdown内容') - - # 标签(JSON格式存储) - tags = Column(JSON, nullable=True, comment='标签列表') - - # 用户备注 - notes = Column(Text, nullable=True, comment='用户手动添加的备注') - - # 时间戳 - created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment='创建时间') - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment='更新时间') - - def __repr__(self): - return f"" - - def to_dict(self): - """转换为字典格式""" - return { - 'id': self.id, - 'image_path': self.image_path, - 'ocr_text': self.ocr_text, - 'category': self.category, - 'ai_result': self.ai_result, - 'tags': self.tags or [], - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - } - - def update_tags(self, tags: list): - """更新标签""" - self.tags = tags - - def add_tag(self, tag: str): - """添加单个标签""" - if self.tags is None: - self.tags = [] - if tag not in self.tags: - self.tags.append(tag) - - -# 数据库连接管理 -class DatabaseManager: - """数据库管理器 - 负责连接和会话管理""" - - def __init__(self, db_path: str = "sqlite:///cutnthink.db"): - """ - 初始化数据库管理器 - - Args: - db_path: 数据库连接路径,默认使用SQLite - """ - self.db_path = db_path - self.engine = None - self.SessionLocal = None - - def init_db(self, db_path: Optional[str] = None): - """ - 初始化数据库连接和表结构 - - Args: - db_path: 可选的数据库路径,如果提供则覆盖初始化时的路径 - """ - if db_path: - self.db_path = db_path - - # 创建数据库引擎 - self.engine = create_engine( - self.db_path, - echo=False, # 不输出SQL日志 - connect_args={"check_same_thread": False} # SQLite特定配置 - ) - - # 创建会话工厂 - self.SessionLocal = sessionmaker( - autocommit=False, - autoflush=False, - bind=self.engine - ) - - # 创建所有表 - BaseModel.metadata.create_all(bind=self.engine) - - def get_session(self): - """ - 获取数据库会话 - - Returns: - SQLAlchemy Session对象 - """ - if self.SessionLocal is None: - raise RuntimeError("数据库未初始化,请先调用 init_db() 方法") - return self.SessionLocal() - - def close(self): - """关闭数据库连接""" - if self.engine: - self.engine.dispose() - self.engine = None - self.SessionLocal = None - - -# 全局数据库管理器实例 -db_manager = DatabaseManager() - - -def init_database(db_path: str = "sqlite:////home/congsh/CodeSpace/ClaudeSpace/CutThenThink/data/cutnthink.db"): - """ - 初始化数据库的便捷函数 - - Args: - db_path: 数据库文件路径 - - Returns: - DatabaseManager实例 - """ - db_manager.init_db(db_path) - return db_manager - - -def get_db(): - """ - 获取数据库会话的便捷函数 - - Returns: - SQLAlchemy Session对象 - - Example: - >>> session = get_db() - >>> try: - ... # 使用session进行数据库操作 - ... records = session.query(Record).all() - ... finally: - ... session.close() - """ - return db_manager.get_session() diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py new file mode 100644 index 0000000..a99abd9 --- /dev/null +++ b/src/plugins/__init__.py @@ -0,0 +1,6 @@ +""" +可选插件模块 +""" +from .ocr import get_ocr_plugin, OCRPlugin + +__all__ = ['get_ocr_plugin', 'OCRPlugin'] diff --git a/src/plugins/ocr.py b/src/plugins/ocr.py new file mode 100644 index 0000000..4357648 --- /dev/null +++ b/src/plugins/ocr.py @@ -0,0 +1,105 @@ +""" +可选的 OCR 插件 +使用 RapidOCR 实现轻量级文字识别 +只有在安装了 rapidocr 时才能使用 +""" +from typing import Optional, List +from pathlib import Path + + +class OCRPlugin: + """OCR 插件基类""" + + def __init__(self): + self.available = False + self.engine = None + + def is_available(self) -> bool: + """检查 OCR 是否可用""" + return self.available + + def recognize(self, image_path: str) -> tuple[bool, str, Optional[str]]: + """ + 识别图片中的文字 + + Returns: + (success, text, error) - 成功标志、识别的文本、错误信息 + """ + raise NotImplementedError + + +class RapidOCRPlugin(OCRPlugin): + """RapidOCR 插件实现""" + + def __init__(self): + super().__init__() + self._init_engine() + + def _init_engine(self): + """初始化 RapidOCR 引擎""" + try: + from rapidocr import RapidOCR + self.engine = RapidOCR() + self.available = True + except ImportError: + self.available = False + self.engine = None + + def recognize(self, image_path: str) -> tuple[bool, str, Optional[str]]: + """识别图片中的文字""" + if not self.is_available(): + return False, "", "RapidOCR 未安装,运行: pip install rapidocr onnxruntime" + + try: + result = self.engine(image_path) + if result and len(result) > 1: + # result[0] 是检测框,result[1] 是文本列表 + text_list = result[1] + if text_list: + # 合并所有识别的文本 + text = '\n'.join(text_list) + return True, text, None + return False, "", "未识别到文字" + + except Exception as e: + return False, "", f"OCR 识别失败: {str(e)}" + + +class DummyOCRPlugin(OCRPlugin): + """假的 OCR 插件(用于测试)""" + + def __init__(self): + super().__init__() + self.available = True + + def recognize(self, image_path: str) -> tuple[bool, str, Optional[str]]: + """模拟 OCR""" + return True, "[模拟 OCR 结果] 这是测试文本", None + + +def get_ocr_plugin() -> OCRPlugin: + """ + 获取可用的 OCR 插件 + + 优先使用 RapidOCR,如果不可用则返回空插件 + """ + plugin = RapidOCRPlugin() + if plugin.is_available(): + return plugin + + # 返回不可用的插件 + return plugin + + +if __name__ == "__main__": + # 测试 OCR + plugin = get_ocr_plugin() + + if plugin.is_available(): + print("RapidOCR 可用") + else: + print("RapidOCR 不可用,请安装: pip install rapidocr onnxruntime") + + # 测试识别(需要提供图片路径) + # success, text, error = plugin.recognize("test.png") + # print(f"识别结果: {text}") diff --git a/src/utils/clipboard.py b/src/utils/clipboard.py deleted file mode 100644 index b767748..0000000 --- a/src/utils/clipboard.py +++ /dev/null @@ -1,304 +0,0 @@ -""" -剪贴板工具模块 - -提供跨平台的剪贴板操作功能 -""" - -import logging -from typing import Optional - -logger = logging.getLogger(__name__) - - -class ClipboardError(Exception): - """剪贴板操作错误""" - pass - - -class ClipboardManager: - """ - 剪贴板管理器 - - 封装不同平台的剪贴板操作 - """ - - def __init__(self): - """初始化剪贴板管理器""" - self._pyperclip = None - self._init_backend() - - def _init_backend(self): - """ - 初始化剪贴板后端 - - 尝试多种剪贴板库 - """ - # 优先使用 pyperclip - try: - import pyperclip - self._pyperclip = pyperclip - logger.debug("使用 pyperclip 作为剪贴板后端") - return - except ImportError: - logger.debug("pyperclip 未安装") - - # 备选方案:使用 tkinter - try: - import tkinter - self._tkinter = tkinter - logger.debug("使用 tkinter 作为剪贴板后端") - except ImportError: - logger.warning("无法初始化剪贴板后端") - - def copy(self, text: str) -> bool: - """ - 复制文本到剪贴板 - - Args: - text: 要复制的文本 - - Returns: - 是否复制成功 - """ - if not text: - logger.warning("尝试复制空文本") - return False - - try: - # 优先使用 pyperclip - if self._pyperclip: - self._pyperclip.copy(text) - logger.info(f"已复制到剪贴板(pyperclip),长度: {len(text)} 字符") - return True - - # 备选:使用 tkinter - if hasattr(self, '_tkinter'): - # 创建一个隐藏的窗口 - root = self._tkinter.Tk() - root.withdraw() # 隐藏窗口 - root.clipboard_clear() - root.clipboard_append(text) - root.update() # 保持剪贴板内容 - root.destroy() - - logger.info(f"已复制到剪贴板(tkinter),长度: {len(text)} 字符") - return True - - logger.error("没有可用的剪贴板后端") - return False - - except Exception as e: - logger.error(f"复制到剪贴板失败: {e}", exc_info=True) - raise ClipboardError(f"复制失败: {e}") - - def paste(self) -> Optional[str]: - """ - 从剪贴板粘贴文本 - - Returns: - 剪贴板中的文本,如果失败则返回 None - """ - try: - # 优先使用 pyperclip - if self._pyperclip: - text = self._pyperclip.paste() - logger.info(f"从剪贴板粘贴(pyperclip),长度: {len(text) if text else 0} 字符") - return text - - # 备选:使用 tkinter - if hasattr(self, '_tkinter'): - root = self._tkinter.Tk() - root.withdraw() - try: - text = root.clipboard_get() - root.destroy() - logger.info(f"从剪贴板粘贴(tkinter),长度: {len(text) if text else 0} 字符") - return text - except self._tkinter.TclError: - root.destroy() - logger.warning("剪贴板为空或包含非文本内容") - return None - - logger.error("没有可用的剪贴板后端") - return None - - except Exception as e: - logger.error(f"从剪贴板粘贴失败: {e}", exc_info=True) - raise ClipboardError(f"粘贴失败: {e}") - - def clear(self) -> bool: - """ - 清空剪贴板 - - Returns: - 是否清空成功 - """ - return self.copy("") - - def is_available(self) -> bool: - """ - 检查剪贴板功能是否可用 - - Returns: - 是否可用 - """ - return self._pyperclip is not None or hasattr(self, '_tkinter') - - -# 全局剪贴板管理器 -_clipboard_manager: Optional[ClipboardManager] = None - - -def get_clipboard_manager() -> ClipboardManager: - """ - 获取全局剪贴板管理器 - - Returns: - ClipboardManager 实例 - """ - global _clipboard_manager - if _clipboard_manager is None: - _clipboard_manager = ClipboardManager() - return _clipboard_manager - - -def copy_to_clipboard(text: str) -> bool: - """ - 复制文本到剪贴板(便捷函数) - - Args: - text: 要复制的文本 - - Returns: - 是否复制成功 - - Example: - >>> from src.utils.clipboard import copy_to_clipboard - >>> copy_to_clipboard("Hello, World!") - True - """ - manager = get_clipboard_manager() - return manager.copy(text) - - -def paste_from_clipboard() -> Optional[str]: - """ - 从剪贴板粘贴文本(便捷函数) - - Returns: - 剪贴板中的文本,如果失败则返回 None - - Example: - >>> from src.utils.clipboard import paste_from_clipboard - >>> text = paste_from_clipboard() - >>> print(text) - Hello, World! - """ - manager = get_clipboard_manager() - return manager.paste() - - -def clear_clipboard() -> bool: - """ - 清空剪贴板(便捷函数) - - Returns: - 是否清空成功 - - Example: - >>> from src.utils.clipboard import clear_clipboard - >>> clear_clipboard() - True - """ - manager = get_clipboard_manager() - return manager.clear() - - -def is_clipboard_available() -> bool: - """ - 检查剪贴板功能是否可用(便捷函数) - - Returns: - 是否可用 - - Example: - >>> from src.utils.clipboard import is_clipboard_available - >>> if is_clipboard_available(): - ... copy_to_clipboard("test") - """ - manager = get_clipboard_manager() - return manager.is_available() - - -# Markdown 格式化工具 -def format_as_markdown( - title: str, - content: str, - category: str = "", - tags: list = None, - metadata: dict = None -) -> str: - """ - 格式化为 Markdown - - Args: - title: 标题 - content: 内容 - category: 分类 - tags: 标签列表 - metadata: 元数据 - - Returns: - Markdown 格式的字符串 - """ - lines = [] - - # 标题 - lines.append(f"# {title}\n") - - # 元数据 - if category or tags: - meta_lines = [] - if category: - meta_lines.append(f"**分类**: {category}") - if tags: - meta_lines.append(f"**标签**: {', '.join(tags)}") - if meta_lines: - lines.append(" | ".join(meta_lines) + "\n") - - lines.append("---\n") - - # 内容 - lines.append(content) - - # 额外元数据 - if metadata: - lines.append("\n---\n\n**元数据**:\n") - for key, value in metadata.items(): - lines.append(f"- {key}: {value}") - - return "\n".join(lines) - - -def copy_markdown_result( - title: str, - content: str, - category: str = "", - tags: list = None, - metadata: dict = None -) -> bool: - """ - 复制 Markdown 格式的结果到剪贴板 - - Args: - title: 标题 - content: 内容 - category: 分类 - tags: 标签列表 - metadata: 元数据 - - Returns: - 是否复制成功 - """ - markdown_text = format_as_markdown(title, content, category, tags, metadata) - return copy_to_clipboard(markdown_text) diff --git a/src/utils/logger.py b/src/utils/logger.py deleted file mode 100644 index 034b47a..0000000 --- a/src/utils/logger.py +++ /dev/null @@ -1,409 +0,0 @@ -""" -日志工具模块 - -提供统一的日志配置和管理功能 -""" - -import logging -import sys -from pathlib import Path -from datetime import datetime -from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler -from typing import Optional - - -class ColoredFormatter(logging.Formatter): - """ - 彩色日志格式化器 - - 为不同级别的日志添加颜色 - """ - - # ANSI 颜色代码 - COLORS = { - 'DEBUG': '\033[36m', # 青色 - 'INFO': '\033[32m', # 绿色 - 'WARNING': '\033[33m', # 黄色 - 'ERROR': '\033[31m', # 红色 - 'CRITICAL': '\033[35m', # 紫色 - } - RESET = '\033[0m' - - def format(self, record): - # 添加颜色 - if record.levelname in self.COLORS: - record.levelname = f"{self.COLORS[record.levelname]}{record.levelname}{self.RESET}" - - return super().format(record) - - -class LoggerManager: - """ - 日志管理器 - - 负责配置和管理应用程序日志 - """ - - def __init__( - self, - name: str = "CutThenThink", - log_dir: Optional[Path] = None, - level: str = "INFO", - console_output: bool = True, - file_output: bool = True, - colored_console: bool = True - ): - """ - 初始化日志管理器 - - Args: - name: 日志器名称 - log_dir: 日志文件目录 - level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL) - console_output: 是否输出到控制台 - file_output: 是否输出到文件 - colored_console: 控制台是否使用彩色输出 - """ - self.name = name - self.log_dir = log_dir - self.level = getattr(logging, level.upper(), logging.INFO) - self.console_output = console_output - self.file_output = file_output - self.colored_console = colored_console - - self.logger: Optional[logging.Logger] = None - - def setup(self) -> logging.Logger: - """ - 设置日志系统 - - Returns: - 配置好的 Logger 对象 - """ - if self.logger is not None: - return self.logger - - # 创建日志器 - self.logger = logging.getLogger(self.name) - self.logger.setLevel(self.level) - self.logger.handlers.clear() # 清除已有的处理器 - - # 日志格式 - log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - date_format = '%Y-%m-%d %H:%M:%S' - - # 控制台处理器 - if self.console_output: - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(self.level) - - if self.colored_console: - console_formatter = ColoredFormatter(log_format, datefmt=date_format) - else: - console_formatter = logging.Formatter(log_format, datefmt=date_format) - - console_handler.setFormatter(console_formatter) - self.logger.addHandler(console_handler) - - # 文件处理器 - if self.file_output and self.log_dir: - # 确保日志目录存在 - self.log_dir.mkdir(parents=True, exist_ok=True) - - # 主日志文件(按大小轮转) - log_file = self.log_dir / f"{self.name}.log" - file_handler = RotatingFileHandler( - log_file, - maxBytes=10 * 1024 * 1024, # 10MB - backupCount=5, - encoding='utf-8' - ) - file_handler.setLevel(self.level) - file_formatter = logging.Formatter(log_format, datefmt=date_format) - file_handler.setFormatter(file_formatter) - self.logger.addHandler(file_handler) - - # 错误日志文件(单独记录错误和严重错误) - error_file = self.log_dir / f"{self.name}_error.log" - error_handler = RotatingFileHandler( - error_file, - maxBytes=10 * 1024 * 1024, # 10MB - backupCount=5, - encoding='utf-8' - ) - error_handler.setLevel(logging.ERROR) - error_formatter = logging.Formatter(log_format, datefmt=date_format) - error_handler.setFormatter(error_formatter) - self.logger.addHandler(error_handler) - - return self.logger - - def get_logger(self) -> logging.Logger: - """ - 获取日志器 - - Returns: - Logger 对象 - """ - if self.logger is None: - return self.setup() - return self.logger - - def set_level(self, level: str): - """ - 动态设置日志级别 - - Args: - level: 日志级别字符串 - """ - log_level = getattr(logging, level.upper(), logging.INFO) - self.logger.setLevel(log_level) - for handler in self.logger.handlers: - handler.setLevel(log_level) - - -# 全局日志管理器 -_global_logger_manager: Optional[LoggerManager] = None - - -def init_logger( - log_dir: Optional[Path] = None, - level: str = "INFO", - console_output: bool = True, - file_output: bool = True, - colored_console: bool = True -) -> logging.Logger: - """ - 初始化全局日志系统 - - Args: - log_dir: 日志目录 - level: 日志级别 - console_output: 是否输出到控制台 - file_output: 是否输出到文件 - colored_console: 控制台是否彩色 - - Returns: - Logger 对象 - """ - global _global_logger_manager - - # 默认日志目录 - if log_dir is None: - project_root = Path(__file__).parent.parent.parent - log_dir = project_root / "logs" - - _global_logger_manager = LoggerManager( - name="CutThenThink", - log_dir=log_dir, - level=level, - console_output=console_output, - file_output=file_output, - colored_console=colored_console - ) - - return _global_logger_manager.setup() - - -def get_logger(name: Optional[str] = None) -> logging.Logger: - """ - 获取日志器 - - Args: - name: 日志器名称,如果为 None 则返回全局日志器 - - Returns: - Logger 对象 - """ - if _global_logger_manager is None: - init_logger() - - if name is None: - return _global_logger_manager.get_logger() - - # 返回指定名称的子日志器 - return logging.getLogger(f"CutThenThink.{name}") - - -class LogCapture: - """ - 日志捕获器 - - 用于捕获日志并显示在 GUI 中 - """ - - def __init__(self, max_entries: int = 1000): - """ - 初始化日志捕获器 - - Args: - max_entries: 最大保存条目数 - """ - self.max_entries = max_entries - self.entries = [] - self.callbacks = [] - - def add_entry(self, level: str, message: str, timestamp: Optional[datetime] = None): - """ - 添加日志条目 - - Args: - level: 日志级别 - message: 日志消息 - timestamp: 时间戳 - """ - if timestamp is None: - timestamp = datetime.now() - - entry = { - 'timestamp': timestamp, - 'level': level, - 'message': message - } - - self.entries.append(entry) - - # 限制条目数量 - if len(self.entries) > self.max_entries: - self.entries = self.entries[-self.max_entries:] - - # 触发回调 - for callback in self.callbacks: - callback(entry) - - def register_callback(self, callback): - """ - 注册回调函数 - - Args: - callback: 回调函数,接收 entry 参数 - """ - self.callbacks.append(callback) - - def clear(self): - """清空日志""" - self.entries.clear() - - def get_entries(self, level: Optional[str] = None, limit: Optional[int] = None) -> list: - """ - 获取日志条目 - - Args: - level: 过滤级别,None 表示不过滤 - limit: 限制数量 - - Returns: - 日志条目列表 - """ - entries = self.entries - - if level is not None: - entries = [e for e in entries if e['level'] == level] - - if limit is not None: - entries = entries[-limit:] - - return entries - - def get_latest(self, count: int = 10) -> list: - """ - 获取最新的 N 条日志 - - Args: - count: 数量 - - Returns: - 日志条目列表 - """ - return self.entries[-count:] - - -# 全局日志捕获器 -_log_capture: Optional[LogCapture] = None - - -def get_log_capture() -> LogCapture: - """ - 获取全局日志捕获器 - - Returns: - LogCapture 对象 - """ - global _log_capture - if _log_capture is None: - _log_capture = LogCapture() - return _log_capture - - -class LogHandler(logging.Handler): - """ - 自定义日志处理器 - - 将日志发送到 LogCapture - """ - - def __init__(self, capture: LogCapture): - super().__init__() - self.capture = capture - - def emit(self, record): - """ - 发出日志记录 - - Args: - record: 日志记录 - """ - try: - message = self.format(record) - level = record.levelname - timestamp = datetime.fromtimestamp(record.created) - - self.capture.add_entry(level, message, timestamp) - - except Exception: - self.handleError(record) - - -def setup_gui_logging(capture: Optional[LogCapture] = None): - """ - 设置 GUI 日志捕获 - - Args: - capture: 日志捕获器,如果为 None 则使用全局捕获器 - """ - if capture is None: - capture = get_log_capture() - - # 创建处理器 - handler = LogHandler(capture) - handler.setLevel(logging.INFO) - handler.setFormatter(logging.Formatter('%(message)s')) - - # 添加到根日志器 - logging.getLogger("CutThenThink").addHandler(handler) - - -# 便捷函数 -def log_debug(message: str): - """记录 DEBUG 日志""" - get_logger().debug(message) - - -def log_info(message: str): - """记录 INFO 日志""" - get_logger().info(message) - - -def log_warning(message: str): - """记录 WARNING 日志""" - get_logger().warning(message) - - -def log_error(message: str, exc_info: bool = False): - """记录 ERROR 日志""" - get_logger().error(message, exc_info=exc_info) - - -def log_critical(message: str, exc_info: bool = False): - """记录 CRITICAL 日志""" - get_logger().critical(message, exc_info=exc_info)