Compare commits

..

3 Commits

Author SHA1 Message Date
congsh
2d4cd9356e fix: 修复 Windows 打包问题
- 修复 build.bat: 添加 UTF-8 编码设置
- 修复 spec 文件: 移除不存在的 config.yaml.template
- 添加 README.md 作为数据文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:02:48 +08:00
congsh
0ce1d71a90 build: 添加 Windows 打包方案
- 更新 PyInstaller spec 配置
- 简化 build.sh 构建脚本
- 更新 build.bat Windows 打包脚本
- 添加 docs/BUILD.md 打包说明文档

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:50:51 +08:00
41 changed files with 2337 additions and 9451 deletions

View File

@@ -1,20 +1,47 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
"""
CutThenThink - Windows 打包配置
极简截图上传工具的 PyInstaller 配置
"""
import sys
from PyInstaller.building.build_main import Analysis, EXE, COLLECT, PYZ
from PyInstaller.building.api import PKG
# 项目信息
APP_NAME = 'CutThenThink'
APP_VERSION = '2.0.0'
# 分析配置
a = Analysis( a = Analysis(
['src/main.py'], ['src/main.py'],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[('src', 'src')], datas=[
hiddenimports=['PyQt6.QtCore', 'PyQt6.QtGui', 'PyQt6.QtWidgets', 'sqlalchemy'], # 包含 README 作为说明
('README.md', '.'),
],
hiddenimports=[
'PyQt6.QtCore',
'PyQt6.QtGui',
'PyQt6.QtWidgets',
'yaml',
],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[
# 排除测试相关
'test',
'tests',
'pytest',
],
noarchive=False, noarchive=False,
optimize=0, optimize=0,
) )
pyz = PYZ(a.pure)
# 打包配置
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE( exe = EXE(
pyz, pyz,
@@ -22,17 +49,42 @@ exe = EXE(
a.binaries, a.binaries,
a.datas, a.datas,
[], [],
name='CutThenThink', exclude_binaries=True,
name=APP_NAME,
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=True, upx=True, # 使用 UPX 压缩
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=False, console=False, # 无控制台窗口
disable_windowed_traceback=False, disable_windowed_traceback=False,
argv_emulation=False, argv_emulation=False,
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
icon=None, # TODO: 添加图标路径
)
# 收集所有文件
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name=APP_NAME,
)
# 构建 MSI 安装包Windows
pkg = PKG(
coll,
name=APP_NAME,
version=APP_VERSION,
description='极简截图上传工具',
author='CutThenThink',
keywords=['screenshot', 'upload', 'OCR'],
license='MIT',
) )

164
README.md
View File

@@ -1,125 +1,113 @@
# CutThenThink # CutThenThink
智能截图OCR与AI分析工具 **极简截图上传工具**
## 项目简介 ## 项目简介
CutThenThink 是一款基于 PyQt6 的桌面应用程序集成了OCR文字识别和AI智能分析功能。用户可以通过截图、选择区域然后使用OCR提取文字并利用多种AI模型进行智能分析和处理。 CutThenThink 是一个轻量级的桌面截图工具,专注于:
- 📷 快速截图(全屏/区域)
- ☁️ 云端上传(支持多种服务)
- 📁 历史记录管理
- 🔍 可选 OCR 文字识别
## 主要功能 ## 特点
- **智能截图**: 支持多种方式截图(矩形选择、窗口选择、全屏等) - **轻量级**:核心依赖仅 ~50MB
- **OCR识别**: 基于PaddleOCR的高精度文字识别 - **可选 OCR**RapidOCR 插件,按需安装
- **AI分析**: 支持多种AI模型OpenAI GPT、Anthropic Claude等 - **无重型依赖**:移除了 torch、transformers、paddleocr
- **内容编辑**: 内置编辑器,支持图片标注和文字编辑 - **简单配置**YAML 单文件配置
- **历史记录**: 本地数据库保存所有截图和分析记录 - **跨平台**:支持 Windows、macOS、Linux
- **快捷操作**: 全局快捷键支持,快速截图和分析
## 技术栈
- **GUI框架**: PyQt6 6.6.1
- **数据库**: SQLAlchemy 2.0.25
- **OCR引擎**: PaddleOCR 2.7.0.3
- **AI模型**: OpenAI API、Anthropic API
- **图像处理**: Pillow 10.0.0
## 安装 ## 安装
### 环境要求 ### 基础安装
- Python 3.8+
- 操作系统: Windows / macOS / Linux
### 安装步骤
1. 克隆项目
```bash
git clone <repository_url>
cd CutThenThink
```
2. 创建虚拟环境
```bash
python -m venv venv
source venv/bin/activate # Linux/macOS
# 或
venv\Scripts\activate # Windows
```
3. 安装依赖
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
python src/main.py
``` ```
4. 配置AI服务 ### 可选:安装 OCR 支持
创建配置文件 `config.yaml`: ```bash
```yaml pip install -r requirements-ocr.txt
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
python src/main.py
```
默认快捷键 | 快捷键 | 功能 |
- `Ctrl+Shift+A`: 截图并分析 |--------|------|
- `Ctrl+Shift+S`: 仅截图 | `Ctrl+Shift+A` | 全屏截图 |
- `Ctrl+Shift+H`: 打开历史记录 | `Ctrl+Shift+R` | 区域截图 |
- `Esc`: 取消截图 | `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/ CutThenThink/
├── src/ ├── src/
│ ├── gui/ # GUI组件 │ ├── main.py # 入口
│ ├── widgets/ # 自定义控件 │ ├── config.py # 简化配置
│ └── styles/ # 样式文件 ├── core/
│ ├── core/ # 核心功能 │ ├── database.py # SQLite 存储
│ ├── models/ # 数据模型 │ ├── screenshot.py # 截图功能
├── config/ # 配置管理 │ └── uploader.py # 上传功能
│ ├── gui/
│ │ └── main_window.py # 主窗口
│ ├── plugins/ # 可选插件
│ │ └── ocr.py # RapidOCR 插件
│ └── utils/ # 工具函数 │ └── utils/ # 工具函数
├── data/ # 数据目录 ├── requirements.txt # 核心依赖
│ ├── images/ # 截图存储 ├── requirements-ocr.txt # 可选 OCR
│ └── database/ # 数据库文件 └── config.yaml # 配置文件
├── requirements.txt # 项目依赖
├── .gitignore # Git忽略文件
└── README.md # 项目说明
``` ```
## 开发计划 ## 开发
- [x] 项目初始化 ```bash
- [ ] 基础GUI框架搭建 # 安装开发依赖
- [ ] 截图功能实现 pip install -r requirements.txt
- [ ] OCR识别集成
- [ ] AI分析功能
- [ ] 数据库存储
- [ ] 历史记录管理
- [ ] 配置系统
- [ ] 快捷键支持
- [ ] 打包发布
## 贡献指南 # 运行
python src/main.py
```
欢迎提交Issue和Pull Request ## 构建
使用 PyInstaller 打包:
```bash
pyinstaller CutThenThink.spec
```
## 许可证 ## 许可证
MIT License MIT License
## 联系方式
- 项目地址: [GitHub Repository]
- 问题反馈: [Issues]

105
build.bat
View File

@@ -1,99 +1,58 @@
@echo off @echo off
REM ================================ REM CutThenThink 极简版本 Windows 打包脚本
REM CutThenThink Windows Build Script - Cloud Only Version REM 使用 UTF-8 编码避免乱码
REM ================================
REM 纯云端版本 - 无需本地 ML 库
REM 设置控制台编码为 UTF-8 chcp 65001 >nul
chcp 65001 >nul 2>&1
cd /d "%~dp0" setlocal enabledelayedexpansion
echo ======================================== echo ===================================
echo CutThenThink 纯云端版本构建 echo CutThenThink v2.0 极简版构建
echo ======================================== echo ===================================
echo. echo.
echo 特点: echo 特点:
echo - OCR 使用云端 API echo - 核心依赖PyQt6, requests, Pillow
echo - AI 使用 API (OpenAI/Anthropic) echo - 可选 OCRRapidOCR 插件
echo - 无需任何本地 ML 库 echo - 无重型依赖torch, transformers, paddleocr
echo ======================================== echo ===================================
echo. echo.
REM Check Python REM [1/4] 清理旧的构建
echo [1/5] 检查 Python... echo [1/4] 清理旧的构建...
python --version 2>nul if exist build rmdir /s /q build 2>nul
if exist dist rmdir /s /q dist 2>nul
REM [2/4] 安装构建依赖
echo.
echo [2/4] 安装构建依赖...
pip install pyinstaller 2>nul
if errorlevel 1 ( if errorlevel 1 (
echo 错误: 未找到 Python echo 警告: pip install 失败,请检查网络连接
pause
exit /b 1
) )
echo. echo.
echo [2/5] 检查源码大小... echo [3/4] 构建可执行文件...
set SIZE=0
for /r %%A in (src\*) do (
set /a SIZE+=%%~zA
)
if %SIZE% GTR 5242880 (
echo 警告: src 目录大小超过 5MB构建的 exe 可能会很大
echo 当前大小: %SIZE% 字节
echo.
)
echo. echo.
echo [3/5] 安装核心依赖... python -m PyInstaller CutThenThink.spec --clean
python -m pip install --user pyinstaller 2>nul
python -m pip install --user "PyQt6>=6.7.0" 2>nul
python -m pip install --user "SQLAlchemy>=2.0.36" 2>nul
python -m pip install --user openai anthropic 2>nul
python -m pip install --user requests pyyaml pillow pyperclip 2>nul
echo.
echo [4/5] 清理旧的构建...
if exist build rmdir /s /q build
if exist dist rmdir /s /q dist
echo.
echo [5/5] 开始构建...
python -m PyInstaller ^
--noconfirm ^
--name "CutThenThink" ^
--windowed ^
--onefile ^
--add-data "src:src" ^
--hidden-import=PyQt6.QtCore ^
--hidden-import=PyQt6.QtGui ^
--hidden-import=PyQt6.QtWidgets ^
--hidden-import=sqlalchemy ^
--hidden-import=sqlalchemy.orm ^
--hidden-import=PIL ^
--hidden-import=PIL.Image ^
--hidden-import=pyperclip ^
--hidden-import=yaml ^
--hidden-import=requests ^
--hidden-import=openai ^
--hidden-import=anthropic ^
--collect-all pyqt6 ^
src/main.py
if errorlevel 1 ( if errorlevel 1 (
echo. echo.
echo ================================ echo ===================================
echo 构建失败! echo 构建失败!
echo ================================ echo ===================================
pause pause
exit /b 1 exit /b 1
) )
echo. echo.
echo ================================ echo ===================================
echo 构建成功! echo 构建成功!
echo ================================ echo ===================================
echo 可执行文件: dist\CutThenThink.exe
echo. echo.
echo 首次运行请配置: echo 输出位置dist\CutThenThink\
echo - AI API Key (OpenAI/Anthropic) echo.
echo - 云端 OCR API echo 首次运行前请配置:
echo 1. 可选安装 OCRpip install -r requirements-ocr.txt
echo 2. 配置文件:%%USERPROFILE%%\.cutthenthink\config.yaml
echo. echo.
pause pause

View File

@@ -1,76 +1,46 @@
#!/bin/bash #!/bin/bash
# CutThenThink 纯云端版本打包脚本 # CutThenThink 极简版本打包脚本
# 无需任何本地 ML 库
set -e set -e
echo "===================================" echo "==================================="
echo "CutThenThink 纯云端版本构建" echo "CutThenThink v2.0 极简版构建"
echo "===================================" echo "==================================="
echo "" echo ""
echo "特点:" echo "特点:"
echo "- OCR 使用云端 API" echo "- 核心依赖PyQt6, requests, Pillow"
echo "- AI 使用 API (OpenAI/Anthropic)" echo "- 可选 OCRRapidOCR 插件"
echo "- 无需任何本地 ML 库" echo "- 无重型依赖torch, transformers, paddleocr"
echo "===================================" echo "==================================="
# 使用系统Python和pip # 检测 Python
PYTHON="python3" PYTHON="python3"
PIP="python3 -m pip" if ! command -v python3 &>/dev/null; then
PYTHON="python"
fi
PIP="$PYTHON -m pip"
echo "" echo ""
echo "[1/4] 安装打包工具..." echo "[1/5] 清理旧的构建..."
$PIP install --user pyinstaller 2>/dev/null || echo " PyInstaller可能已安装"
echo ""
echo "[2/4] 安装核心依赖..."
$PIP install --user "PyQt6>=6.7.0" 2>/dev/null || echo " PyQt6可能已安装"
$PIP install --user "SQLAlchemy>=2.0.36" 2>/dev/null || echo " SQLAlchemy可能已安装"
$PIP install --user openai anthropic 2>/dev/null || echo " AI库可能已安装"
$PIP install --user requests pyyaml pillow pyperclip 2>/dev/null || echo " 工具库可能已安装"
echo ""
echo "[3/4] 清理旧的构建..."
rm -rf build dist rm -rf build dist
echo "" echo ""
echo "[4/4] 开始构建..." echo "[2/5] 安装构建依赖..."
$PYTHON -m PyInstaller \ $PIP install --user pyinstaller 2>/dev/null || echo " PyInstaller 已安装"
--name "CutThenThink" \
--windowed \ echo ""
--onefile \ echo "[3/5] 构建可执行文件..."
--add-data "src:src" \ $PYTHON -m PyInstaller CutThenThink.spec --clean
--hidden-import=PyQt6.QtCore \
--hidden-import=PyQt6.QtGui \
--hidden-import=PyQt6.QtWidgets \
--hidden-import=sqlalchemy \
--hidden-import=sqlalchemy.orm \
--hidden-import=PIL \
--hidden-import=PIL.Image \
--hidden-import=pyperclip \
--hidden-import=yaml \
--hidden-import=requests \
--hidden-import=openai \
--hidden-import=anthropic \
--collect-all pyqt6 \
src/main.py
echo "" echo ""
echo "===================================" echo "==================================="
echo "构建完成!" echo "构建完成!"
echo "可执行文件: dist/CutThenThink" echo ""
echo "输出位置:"
echo " - dist/CutThenThink/ # 可执行文件"
echo ""
echo "首次运行前请配置:"
echo " 1. 可选安装 OCRpip install -r requirements-ocr.txt"
echo " 2. 配置文件:~/.cutthenthink/config.yaml"
echo "===================================" echo "==================================="
echo ""
echo "首次运行请配置:"
echo "- AI API Key (OpenAI/Anthropic)"
echo "- 云端 OCR API"
echo ""
# 测试运行提示
echo ""
read -p "是否测试运行?(y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "启动测试..."
./dist/CutThenThink
fi

View File

@@ -1,74 +1,136 @@
# CutThenThink 打包指南 # CutThenThink 打包指南
## 方式一:使用 build.sh 脚本(推荐) ## Windows 打包
在您的本地环境(有管理员权限)中运行: ### 方法一:使用批处理脚本(推荐)
```bash ```cmd
cd /path/to/CutThenThink # 双击运行
bash build.sh build.bat
``` ```
--- ### 方法二:手动命令
## 方式二:手动打包 ```cmd
# 1. 安装依赖
### 1. 安装 PyInstaller
```bash
# 使用 pipx推荐
pipx install pyinstaller
# 或使用系统包管理器
sudo apt install python3-pyinstaller
```
### 2. 安装项目依赖
```bash
pip install -r requirements.txt pip install -r requirements.txt
# 2. 安装 PyInstaller
pip install pyinstaller
# 3. 构建
python -m PyInstaller CutThenThink.spec --clean
``` ```
### 3. 执行打包 ### 输出位置
```
dist/
└── CutThenThink/
├── CutThenThink.exe # 主程序
└── _internal/ # 运行时依赖
```
### 可选:安装 OCR 支持
```cmd
pip install -r requirements-ocr.txt
```
## Linux/macOS 打包
### 使用 Shell 脚本
```bash ```bash
python3 -m PyInstaller \ # 添加执行权限
--name "CutThenThink" \ chmod +x build.sh
--windowed \
--onefile \ # 运行
--add-data "src:src" \ ./build.sh
--hidden-import=PyQt6.QtCore \
--hidden-import=PyQt6.QtGui \
--hidden-import=PyQt6.QtWidgets \
--hidden-import=sqlalchemy \
src/main.py
``` ```
### 4. 测试运行 ### 手动命令
```bash ```bash
./dist/CutThenThink # 1. 安装依赖
pip install -r requirements.txt
# 2. 安装 PyInstaller
pip install pyinstaller
# 3. 构建
python -m PyInstaller CutThenThink.spec --clean
``` ```
--- ## 打包说明
## 打包参数说明 ### PyInstaller 配置
| 参数 | 说明 | - `--onefile`: 打包成单个 EXE
|------|------| - `--windowed`: 无控制台窗口
| `--name` | 应用名称 | - `--upx`: 使用 UPX 压缩(减小体积)
| `--windowed` | 无控制台窗口 | - `--clean`: 清理旧的构建
| `--onefile` | 单文件打包 |
| `--add-data` | 添加数据文件(源代码) |
| `--hidden-import` | 隐式导入模块 |
--- ### 包含的隐式导入
## 打包后 - PyQt6.QtCore
- PyQt6.QtGui
- PyQt6.QtWidgets
- yaml
可执行文件位置:`dist/CutThenThink` ### 排除的模块
分发时建议: - test, tests, pytest
1.`dist/CutThenThink` 打包为 tar.gz
2. 创建安装脚本 ## 首次运行配置
3. 包含 README 说明
程序首次运行时会创建配置文件:
**Windows**: `%USERPROFILE%\.cutthenthink\config.yaml`
**Linux/macOS**: `~/.cutthenthink/config.yaml`
```yaml
upload:
provider: custom
endpoint: https://your-server.com/upload
api_key: your-api-key
auto_copy: true
screenshot:
format: png
save_path: ~/Pictures/Screenshots
hotkeys:
capture: Ctrl+Shift+A
region: Ctrl+Shift+R
upload: Ctrl+Shift+U
ocr:
enabled: false
auto_copy: false
```
## 故障排除
### PyInstaller 构建失败
1. 确保 PyInstaller 已安装:`pip list | grep pyinstaller`
2. 检查 Python 版本:需要 Python 3.8+
3. 清理缓存:删除 `build/``dist/` 目录后重试
### 运行时错误
1. 检查是否有防火墙阻止
2. 检查 OCR 功能是否可选安装
3. 查看日志文件(如果有)
## 体积优化建议
当前打包体积预估:
- 核心依赖:~50MB
- 可选 OCR+10MB
如需进一步减小体积:
1. 使用 `--exclude-module` 排除不需要的模块
2. 启用 UPX 压缩(已启用)
3. 使用虚拟环境减少依赖

302
docs/需求分析报告.md Normal file
View File

@@ -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 功能不完整
**设计文档承诺**
- 内置轻量 OCRPaddleOCR 本地运行)
- 云端 OCR API百度/腾讯/阿里云)
- 自动降级机制
**当前实现**
- 仅支持云端 API 调用
- 无任何本地 OCR 实现
- 无任何第三方 OCR 服务集成
- **OCR 功能实际上无法使用**
### 问题三:云端存储未实现
**设计文档承诺**
- WebDAV 支持
- 阿里云 OSS 支持
- AWS S3 支持
- 同步状态显示
**当前实现**
- 仅有简单的 JSON 文件存储
- 无任何云端存储实现
- 云存储配置类存在但无实际功能
### 问题四AI 分类功能依赖外部服务
**现状**
- 完全依赖第三方 APIOpenAI/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*

View File

@@ -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 - 简化版*

8
requirements-ocr.txt Normal file
View File

@@ -0,0 +1,8 @@
# CutThenThink - 可选 OCR 插件
# 安装pip install -r requirements-ocr.txt
# 轻量级 OCR 引擎
rapidocr>=1.3.0
# ONNX 运行时
onnxruntime>=1.16.0

View File

@@ -1,18 +1,17 @@
# CutThenThink 纯云端版本依赖 # CutThenThink - 极简截图上传工具
# 本版本使用云端 API 进行 OCR 和 AI 处理,无需任何本地 ML 库 # 核心依赖
# GUI框架 # GUI 框架
PyQt6>=6.7.0 PyQt6>=6.7.0
# 数据库 # 图片处理
SQLAlchemy>=2.0.36 Pillow>=10.0.0
# AI服务API调用 # HTTP 请求
openai>=1.0.0
anthropic>=0.18.0
# 工具库
requests>=2.31.0 requests>=2.31.0
pyyaml>=6.0.1
pillow>=10.0.0 # 剪贴板
pyperclip>=1.8.2 pyperclip>=1.8.2
# 配置文件
pyyaml>=6.0.1

170
src/config.py Normal file
View File

@@ -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

View File

@@ -1,7 +0,0 @@
"""
配置管理模块
"""
from src.config.settings import Settings, get_config
__all__ = ['Settings', 'get_config']

View File

@@ -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

View File

@@ -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)

234
src/core/database.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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_textOCR 结果将为空")
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

234
src/core/screenshot.py Normal file
View File

@@ -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}")

View File

@@ -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

182
src/core/uploader.py Normal file
View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,24 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
CutThenThink 应用入口 CutThenThink - 极简截图上传工具
截图 → OCR解析 → AI理解并分类 → 形成备注和执行计划 截图 → 上传 → 分类浏览
核心功能:
- 截图(全屏/区域)
- 上传到云端
- 历史记录管理
- 可选 OCR 文字识别
""" """
import sys import sys
import os import os
def setup_path(): def setup_path():
"""设置Python路径兼容开发和打包环境""" """设置Python路径兼容开发和打包环境"""
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# PyInstaller打包后的环境 # PyInstaller打包后的环境
# 在打包环境中src目录会被解压到sys._MEIPASS
base_path = sys._MEIPASS base_path = sys._MEIPASS
src_path = os.path.join(base_path, 'src') src_path = os.path.join(base_path, 'src')
if os.path.exists(src_path): if os.path.exists(src_path):
@@ -24,9 +30,22 @@ def setup_path():
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, current_dir) sys.path.insert(0, current_dir)
setup_path() 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__": if __name__ == "__main__":

View File

@@ -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',
]

View File

@@ -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"<Record(id={self.id}, category='{self.category}', image_path='{self.image_path}')>"
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()

6
src/plugins/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
可选插件模块
"""
from .ocr import get_ocr_plugin, OCRPlugin
__all__ = ['get_ocr_plugin', 'OCRPlugin']

105
src/plugins/ocr.py Normal file
View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)