Compare commits
10 Commits
54eeb6f522
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d4cd9356e | ||
|
|
0ce1d71a90 | ||
|
|
e853161975 | ||
|
|
a5e50876a0 | ||
|
|
313e1f40d8 | ||
|
|
6fc126b0fe | ||
|
|
4589289635 | ||
|
|
bb27db586d | ||
|
|
92a258350a | ||
|
|
a5897a1cd8 |
@@ -1,20 +1,47 @@
|
||||
# -*- 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(
|
||||
['src/main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('src', 'src')],
|
||||
hiddenimports=['PyQt6.QtCore', 'PyQt6.QtGui', 'PyQt6.QtWidgets', 'sqlalchemy'],
|
||||
datas=[
|
||||
# 包含 README 作为说明
|
||||
('README.md', '.'),
|
||||
],
|
||||
hiddenimports=[
|
||||
'PyQt6.QtCore',
|
||||
'PyQt6.QtGui',
|
||||
'PyQt6.QtWidgets',
|
||||
'yaml',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
excludes=[
|
||||
# 排除测试相关
|
||||
'test',
|
||||
'tests',
|
||||
'pytest',
|
||||
],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
# 打包配置
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
@@ -22,17 +49,42 @@ exe = EXE(
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='CutThenThink',
|
||||
exclude_binaries=True,
|
||||
name=APP_NAME,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx=True, # 使用 UPX 压缩
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
console=False, # 无控制台窗口
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=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',
|
||||
)
|
||||
|
||||
166
README.md
166
README.md
@@ -1,125 +1,113 @@
|
||||
# CutThenThink
|
||||
|
||||
智能截图OCR与AI分析工具
|
||||
**极简截图上传工具**
|
||||
|
||||
## 项目简介
|
||||
|
||||
CutThenThink 是一款基于 PyQt6 的桌面应用程序,集成了OCR文字识别和AI智能分析功能。用户可以通过截图、选择区域,然后使用OCR提取文字,并利用多种AI模型进行智能分析和处理。
|
||||
CutThenThink 是一个轻量级的桌面截图工具,专注于:
|
||||
- 📷 快速截图(全屏/区域)
|
||||
- ☁️ 云端上传(支持多种服务)
|
||||
- 📁 历史记录管理
|
||||
- 🔍 可选 OCR 文字识别
|
||||
|
||||
## 主要功能
|
||||
## 特点
|
||||
|
||||
- **智能截图**: 支持多种方式截图(矩形选择、窗口选择、全屏等)
|
||||
- **OCR识别**: 基于PaddleOCR的高精度文字识别
|
||||
- **AI分析**: 支持多种AI模型(OpenAI GPT、Anthropic Claude等)
|
||||
- **内容编辑**: 内置编辑器,支持图片标注和文字编辑
|
||||
- **历史记录**: 本地数据库保存所有截图和分析记录
|
||||
- **快捷操作**: 全局快捷键支持,快速截图和分析
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **GUI框架**: PyQt6 6.6.1
|
||||
- **数据库**: SQLAlchemy 2.0.25
|
||||
- **OCR引擎**: PaddleOCR 2.7.0.3
|
||||
- **AI模型**: OpenAI API、Anthropic API
|
||||
- **图像处理**: Pillow 10.0.0
|
||||
- **轻量级**:核心依赖仅 ~50MB
|
||||
- **可选 OCR**:RapidOCR 插件,按需安装
|
||||
- **无重型依赖**:移除了 torch、transformers、paddleocr
|
||||
- **简单配置**:YAML 单文件配置
|
||||
- **跨平台**:支持 Windows、macOS、Linux
|
||||
|
||||
## 安装
|
||||
|
||||
### 环境要求
|
||||
### 基础安装
|
||||
|
||||
- Python 3.8+
|
||||
- 操作系统: Windows / macOS / Linux
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. 克隆项目
|
||||
```bash
|
||||
git clone <repository_url>
|
||||
cd CutThenThink
|
||||
```
|
||||
|
||||
2. 创建虚拟环境
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/macOS
|
||||
# 或
|
||||
venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. 安装依赖
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
4. 配置AI服务
|
||||
### 可选:安装 OCR 支持
|
||||
|
||||
创建配置文件 `config.yaml`:
|
||||
```yaml
|
||||
ai:
|
||||
provider: "openai" # 或 "anthropic"
|
||||
openai:
|
||||
api_key: "your-openai-api-key"
|
||||
model: "gpt-4"
|
||||
anthropic:
|
||||
api_key: "your-anthropic-api-key"
|
||||
model: "claude-3-sonnet-20240229"
|
||||
```bash
|
||||
pip install -r requirements-ocr.txt
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
启动应用:
|
||||
```bash
|
||||
python src/main.py
|
||||
```
|
||||
### 快捷键
|
||||
|
||||
默认快捷键:
|
||||
- `Ctrl+Shift+A`: 截图并分析
|
||||
- `Ctrl+Shift+S`: 仅截图
|
||||
- `Ctrl+Shift+H`: 打开历史记录
|
||||
- `Esc`: 取消截图
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| `Ctrl+Shift+A` | 全屏截图 |
|
||||
| `Ctrl+Shift+R` | 区域截图 |
|
||||
| `Ctrl+Shift+U` | 上传最后截图 |
|
||||
| `Esc` | 退出 |
|
||||
|
||||
### 配置
|
||||
|
||||
配置文件位于 `~/.cutthenthink/config.yaml`:
|
||||
|
||||
```yaml
|
||||
upload:
|
||||
provider: custom # custom, telegraph, imgur
|
||||
endpoint: https://...
|
||||
api_key: your-key
|
||||
auto_copy: true
|
||||
|
||||
screenshot:
|
||||
format: png # png, jpg, webp
|
||||
save_path: ~/Pictures/Screenshots
|
||||
|
||||
hotkeys:
|
||||
capture: Ctrl+Shift+A
|
||||
region: Ctrl+Shift+R
|
||||
upload: Ctrl+Shift+U
|
||||
|
||||
ocr:
|
||||
enabled: false # 是否启用 OCR
|
||||
auto_copy: false # 识别后自动复制
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
CutThenThink/
|
||||
├── src/
|
||||
│ ├── gui/ # GUI组件
|
||||
│ │ ├── widgets/ # 自定义控件
|
||||
│ │ └── styles/ # 样式文件
|
||||
│ ├── core/ # 核心功能
|
||||
│ ├── models/ # 数据模型
|
||||
│ ├── config/ # 配置管理
|
||||
│ └── utils/ # 工具函数
|
||||
├── data/ # 数据目录
|
||||
│ ├── images/ # 截图存储
|
||||
│ └── database/ # 数据库文件
|
||||
├── requirements.txt # 项目依赖
|
||||
├── .gitignore # Git忽略文件
|
||||
└── README.md # 项目说明
|
||||
│ ├── main.py # 入口
|
||||
│ ├── config.py # 简化配置
|
||||
│ ├── core/
|
||||
│ │ ├── database.py # SQLite 存储
|
||||
│ │ ├── screenshot.py # 截图功能
|
||||
│ │ └── uploader.py # 上传功能
|
||||
│ ├── gui/
|
||||
│ │ └── main_window.py # 主窗口
|
||||
│ ├── plugins/ # 可选插件
|
||||
│ │ └── ocr.py # RapidOCR 插件
|
||||
│ └── utils/ # 工具函数
|
||||
├── requirements.txt # 核心依赖
|
||||
├── requirements-ocr.txt # 可选 OCR
|
||||
└── config.yaml # 配置文件
|
||||
```
|
||||
|
||||
## 开发计划
|
||||
## 开发
|
||||
|
||||
- [x] 项目初始化
|
||||
- [ ] 基础GUI框架搭建
|
||||
- [ ] 截图功能实现
|
||||
- [ ] OCR识别集成
|
||||
- [ ] AI分析功能
|
||||
- [ ] 数据库存储
|
||||
- [ ] 历史记录管理
|
||||
- [ ] 配置系统
|
||||
- [ ] 快捷键支持
|
||||
- [ ] 打包发布
|
||||
```bash
|
||||
# 安装开发依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
## 贡献指南
|
||||
# 运行
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
欢迎提交Issue和Pull Request!
|
||||
## 构建
|
||||
|
||||
使用 PyInstaller 打包:
|
||||
|
||||
```bash
|
||||
pyinstaller CutThenThink.spec
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 项目地址: [GitHub Repository]
|
||||
- 问题反馈: [Issues]
|
||||
|
||||
104
build-cloud.bat
Normal file
104
build-cloud.bat
Normal file
@@ -0,0 +1,104 @@
|
||||
@echo off
|
||||
REM ================================
|
||||
REM CutThenThink Cloud-Only Build Script
|
||||
REM ================================
|
||||
REM 纯云端版本 - 无本地 ML 库依赖
|
||||
REM OCR: 使用云端 API
|
||||
REM AI: 使用 API (OpenAI/Anthropic/etc)
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo CutThenThink 纯云端版本打包
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 此版本特点:
|
||||
echo - OCR 使用云端 API
|
||||
echo - AI 使用 API(OpenAI/Anthropic)
|
||||
echo - 无需安装 PaddleOCR/Torch 等本地库
|
||||
echo - 体积更小,兼容性更好
|
||||
echo.
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Check Python
|
||||
echo [1/5] 检查 Python...
|
||||
python --version 2>nul
|
||||
if errorlevel 1 (
|
||||
echo 错误: 未找到 Python
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2/5] 安装核心依赖(无 ML 库)...
|
||||
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 numpy 2>nul
|
||||
|
||||
echo.
|
||||
echo [3/5] 清理旧的构建...
|
||||
if exist build rmdir /s /q build
|
||||
if exist dist rmdir /s /q dist
|
||||
|
||||
echo.
|
||||
echo [4/5] 开始构建...
|
||||
python -m PyInstaller ^
|
||||
--noconfirm ^
|
||||
--name "CutThenThink" ^
|
||||
--windowed ^
|
||||
--onefile ^
|
||||
--add-data "src:src" ^
|
||||
--runtime-hook=pyi_hooks/pyi_rth_ignore_torch.py ^
|
||||
--additional-hooks-dir=pyi_hooks ^
|
||||
--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=numpy ^
|
||||
--hidden-import=pyperclip ^
|
||||
--hidden-import=yaml ^
|
||||
--hidden-import=requests ^
|
||||
--hidden-import=openai ^
|
||||
--hidden-import=anthropic ^
|
||||
--collect-all pyqt6 ^
|
||||
--exclude-module=torch ^
|
||||
--exclude-module=transformers ^
|
||||
--exclude-module=tensorflow ^
|
||||
--exclude-module=onnx ^
|
||||
--exclude-module=paddle ^
|
||||
--exclude-module=paddleocr ^
|
||||
--exclude-module=paddlepaddle ^
|
||||
--exclude-module=sentencepiece ^
|
||||
--exclude-module=tokenizers ^
|
||||
--exclude-module=diffusers ^
|
||||
--exclude-module=accelerate ^
|
||||
--exclude-module=datasets ^
|
||||
src/main.py
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ================================
|
||||
echo 构建失败!
|
||||
echo ================================
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================
|
||||
echo 构建成功!
|
||||
echo ================================
|
||||
echo 可执行文件: dist\CutThenThink.exe
|
||||
echo.
|
||||
echo 首次运行请配置:
|
||||
echo - AI API Key (OpenAI/Anthropic)
|
||||
echo - 云端 OCR API
|
||||
echo.
|
||||
pause
|
||||
103
build.bat
103
build.bat
@@ -1,83 +1,58 @@
|
||||
@echo off
|
||||
REM ================================
|
||||
REM CutThenThink Windows Build Script
|
||||
REM ================================
|
||||
REM CutThenThink 极简版本 Windows 打包脚本
|
||||
REM 使用 UTF-8 编码避免乱码
|
||||
|
||||
REM Change to project directory
|
||||
cd /d "%~dp0"
|
||||
chcp 65001 >nul
|
||||
|
||||
REM Check Python
|
||||
echo Checking Python...
|
||||
python --version 2>nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ===================================
|
||||
echo CutThenThink v2.0 极简版构建
|
||||
echo ===================================
|
||||
echo.
|
||||
echo 特点:
|
||||
echo - 核心依赖:PyQt6, requests, Pillow
|
||||
echo - 可选 OCR:RapidOCR 插件
|
||||
echo - 无重型依赖:torch, transformers, paddleocr
|
||||
echo ===================================
|
||||
echo.
|
||||
|
||||
REM [1/4] 清理旧的构建
|
||||
echo [1/4] 清理旧的构建...
|
||||
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 (
|
||||
echo Python not found. Please install Python 3.8+
|
||||
pause
|
||||
exit /b 1
|
||||
echo 警告: pip install 失败,请检查网络连接
|
||||
)
|
||||
|
||||
REM Get Python version
|
||||
for /f "tokens=2" %%i in ('python --version 2^>^&1') do set PYVER=%%i
|
||||
echo Detected Python %PYVER%
|
||||
|
||||
echo.
|
||||
echo 1/5. Installing PyInstaller...
|
||||
python -m pip install --user pyinstaller 2>nul
|
||||
|
||||
echo [3/4] 构建可执行文件...
|
||||
echo.
|
||||
echo 2/5. Installing dependencies (compatible with Python 3.13)...
|
||||
REM Use SQLAlchemy 2.0.36+ for Python 3.13 compatibility
|
||||
python -m pip install --user "sqlalchemy>=2.0.36" 2>nul
|
||||
python -m pip install --user "PyQt6>=6.7.0" 2>nul
|
||||
python -m pip install --user pyyaml 2>nul
|
||||
python -m pip install --user requests 2>nul
|
||||
python -m pip install --user pillow 2>nul
|
||||
python -m pip install --user pyperclip 2>nul
|
||||
|
||||
REM Install build dependencies
|
||||
python -m pip install --user setuptools 2>nul
|
||||
|
||||
echo.
|
||||
echo 3/5. Cleaning previous build...
|
||||
if exist build rmdir /s /q build
|
||||
if exist dist rmdir /s /q dist
|
||||
|
||||
echo.
|
||||
echo 4/5. Building executable...
|
||||
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=PIL ^
|
||||
--hidden-import=PIL.Image ^
|
||||
--hidden-import=PIL.ImageEnhance ^
|
||||
--hidden-import=PIL.ImageFilter ^
|
||||
--hidden-import=numpy ^
|
||||
--hidden-import=pyperclip ^
|
||||
--collect-all pyqt6 ^
|
||||
src/main.py
|
||||
python -m PyInstaller CutThenThink.spec --clean
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ================================
|
||||
echo Build Failed!
|
||||
echo ================================
|
||||
echo ===================================
|
||||
echo 构建失败!
|
||||
echo ===================================
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================
|
||||
echo Build Complete!
|
||||
echo Executable: dist\CutThenThink.exe
|
||||
echo File size: ~30-50 MB
|
||||
echo ================================
|
||||
echo ===================================
|
||||
echo 构建成功!
|
||||
echo ===================================
|
||||
echo.
|
||||
echo On first run, app will auto-download and install PaddleOCR.
|
||||
echo 输出位置:dist\CutThenThink\
|
||||
echo.
|
||||
echo 首次运行前请配置:
|
||||
echo 1. 可选安装 OCR:pip install -r requirements-ocr.txt
|
||||
echo 2. 配置文件:%%USERPROFILE%%\.cutthenthink\config.yaml
|
||||
echo.
|
||||
pause
|
||||
|
||||
87
build.sh
87
build.sh
@@ -1,71 +1,46 @@
|
||||
#!/bin/bash
|
||||
# CutThenThink 简化打包脚本
|
||||
# CutThenThink 极简版本打包脚本
|
||||
|
||||
set -e
|
||||
|
||||
echo "==================================="
|
||||
echo "CutThenThink 打包脚本"
|
||||
echo "CutThenThink v2.0 极简版构建"
|
||||
echo "==================================="
|
||||
echo ""
|
||||
echo "特点:"
|
||||
echo "- 核心依赖:PyQt6, requests, Pillow"
|
||||
echo "- 可选 OCR:RapidOCR 插件"
|
||||
echo "- 无重型依赖:torch, transformers, paddleocr"
|
||||
echo "==================================="
|
||||
|
||||
# 使用系统Python和pip
|
||||
# 检测 Python
|
||||
PYTHON="python3"
|
||||
PIP="python3 -m pip"
|
||||
|
||||
echo ""
|
||||
echo "1/4. 安装打包工具..."
|
||||
$PIP install --user pyinstaller 2>/dev/null || echo " PyInstaller可能已安装"
|
||||
|
||||
echo ""
|
||||
echo "2/4. 安装项目依赖..."
|
||||
$PIP install --user -r requirements.txt 2>/dev/null || echo " 依赖可能已安装"
|
||||
|
||||
echo ""
|
||||
echo "3/4. 创建应用入口(如果不存在)..."
|
||||
if [ ! -f "src/main.py" ]; then
|
||||
cat > src/main.py << 'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CutThenThink 应用入口
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加src目录到路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
from gui.main_window import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PYEOF
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
PYTHON="python"
|
||||
fi
|
||||
|
||||
PIP="$PYTHON -m pip"
|
||||
|
||||
echo ""
|
||||
echo "4/4. 开始打包..."
|
||||
$PYTHON -m PyInstaller \
|
||||
--name "CutThenThink" \
|
||||
--windowed \
|
||||
--onefile \
|
||||
--add-data "src:src" \
|
||||
--hidden-import=PyQt6.QtCore \
|
||||
--hidden-import=PyQt6.QtGui \
|
||||
--hidden-import=PyQt6.QtWidgets \
|
||||
--hidden-import=sqlalchemy \
|
||||
src/main.py
|
||||
echo "[1/5] 清理旧的构建..."
|
||||
rm -rf build dist
|
||||
|
||||
echo ""
|
||||
echo "[2/5] 安装构建依赖..."
|
||||
$PIP install --user pyinstaller 2>/dev/null || echo " PyInstaller 已安装"
|
||||
|
||||
echo ""
|
||||
echo "[3/5] 构建可执行文件..."
|
||||
$PYTHON -m PyInstaller CutThenThink.spec --clean
|
||||
|
||||
echo ""
|
||||
echo "==================================="
|
||||
echo "打包完成!"
|
||||
echo "可执行文件: dist/CutThenThink"
|
||||
echo "==================================="
|
||||
|
||||
# 测试运行提示
|
||||
echo "构建完成!"
|
||||
echo ""
|
||||
read -p "是否测试运行?(y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "启动测试..."
|
||||
./dist/CutThenThink
|
||||
fi
|
||||
echo "输出位置:"
|
||||
echo " - dist/CutThenThink/ # 可执行文件"
|
||||
echo ""
|
||||
echo "首次运行前请配置:"
|
||||
echo " 1. 可选安装 OCR:pip install -r requirements-ocr.txt"
|
||||
echo " 2. 配置文件:~/.cutthenthink/config.yaml"
|
||||
echo "==================================="
|
||||
|
||||
162
docs/BUILD.md
162
docs/BUILD.md
@@ -1,74 +1,136 @@
|
||||
# CutThenThink 打包指南
|
||||
|
||||
## 方式一:使用 build.sh 脚本(推荐)
|
||||
## Windows 打包
|
||||
|
||||
在您的本地环境(有管理员权限)中运行:
|
||||
### 方法一:使用批处理脚本(推荐)
|
||||
|
||||
```bash
|
||||
cd /path/to/CutThenThink
|
||||
bash build.sh
|
||||
```cmd
|
||||
# 双击运行
|
||||
build.bat
|
||||
```
|
||||
|
||||
---
|
||||
### 方法二:手动命令
|
||||
|
||||
## 方式二:手动打包
|
||||
|
||||
### 1. 安装 PyInstaller
|
||||
|
||||
```bash
|
||||
# 使用 pipx(推荐)
|
||||
pipx install pyinstaller
|
||||
|
||||
# 或使用系统包管理器
|
||||
sudo apt install python3-pyinstaller
|
||||
```
|
||||
|
||||
### 2. 安装项目依赖
|
||||
|
||||
```bash
|
||||
```cmd
|
||||
# 1. 安装依赖
|
||||
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
|
||||
python3 -m PyInstaller \
|
||||
--name "CutThenThink" \
|
||||
--windowed \
|
||||
--onefile \
|
||||
--add-data "src:src" \
|
||||
--hidden-import=PyQt6.QtCore \
|
||||
--hidden-import=PyQt6.QtGui \
|
||||
--hidden-import=PyQt6.QtWidgets \
|
||||
--hidden-import=sqlalchemy \
|
||||
src/main.py
|
||||
# 添加执行权限
|
||||
chmod +x build.sh
|
||||
|
||||
# 运行
|
||||
./build.sh
|
||||
```
|
||||
|
||||
### 4. 测试运行
|
||||
### 手动命令
|
||||
|
||||
```bash
|
||||
./dist/CutThenThink
|
||||
# 1. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. 安装 PyInstaller
|
||||
pip install pyinstaller
|
||||
|
||||
# 3. 构建
|
||||
python -m PyInstaller CutThenThink.spec --clean
|
||||
```
|
||||
|
||||
---
|
||||
## 打包说明
|
||||
|
||||
## 打包参数说明
|
||||
### PyInstaller 配置
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--name` | 应用名称 |
|
||||
| `--windowed` | 无控制台窗口 |
|
||||
| `--onefile` | 单文件打包 |
|
||||
| `--add-data` | 添加数据文件(源代码) |
|
||||
| `--hidden-import` | 隐式导入模块 |
|
||||
- `--onefile`: 打包成单个 EXE
|
||||
- `--windowed`: 无控制台窗口
|
||||
- `--upx`: 使用 UPX 压缩(减小体积)
|
||||
- `--clean`: 清理旧的构建
|
||||
|
||||
---
|
||||
### 包含的隐式导入
|
||||
|
||||
## 打包后
|
||||
- PyQt6.QtCore
|
||||
- PyQt6.QtGui
|
||||
- PyQt6.QtWidgets
|
||||
- yaml
|
||||
|
||||
可执行文件位置:`dist/CutThenThink`
|
||||
### 排除的模块
|
||||
|
||||
分发时建议:
|
||||
1. 将 `dist/CutThenThink` 打包为 tar.gz
|
||||
2. 创建安装脚本
|
||||
3. 包含 README 说明
|
||||
- test, tests, pytest
|
||||
|
||||
## 首次运行配置
|
||||
|
||||
程序首次运行时会创建配置文件:
|
||||
|
||||
**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
302
docs/需求分析报告.md
Normal 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 功能不完整
|
||||
|
||||
**设计文档承诺**:
|
||||
- 内置轻量 OCR(PaddleOCR 本地运行)
|
||||
- 云端 OCR API(百度/腾讯/阿里云)
|
||||
- 自动降级机制
|
||||
|
||||
**当前实现**:
|
||||
- 仅支持云端 API 调用
|
||||
- 无任何本地 OCR 实现
|
||||
- 无任何第三方 OCR 服务集成
|
||||
- **OCR 功能实际上无法使用**
|
||||
|
||||
### 问题三:云端存储未实现
|
||||
|
||||
**设计文档承诺**:
|
||||
- WebDAV 支持
|
||||
- 阿里云 OSS 支持
|
||||
- AWS S3 支持
|
||||
- 同步状态显示
|
||||
|
||||
**当前实现**:
|
||||
- 仅有简单的 JSON 文件存储
|
||||
- 无任何云端存储实现
|
||||
- 云存储配置类存在但无实际功能
|
||||
|
||||
### 问题四:AI 分类功能依赖外部服务
|
||||
|
||||
**现状**:
|
||||
- 完全依赖第三方 API(OpenAI/Claude/通义千问)
|
||||
- 虽然支持 Ollama 本地模型,但需要用户自行部署
|
||||
- 无离线工作能力
|
||||
|
||||
**问题**:
|
||||
- 需要配置 API Key
|
||||
- 产生额外费用
|
||||
- 网络依赖
|
||||
|
||||
### 问题五:项目定位模糊
|
||||
|
||||
| 设计文档 | 实际需求 | 现状 |
|
||||
|---------|---------|------|
|
||||
| 多用户系统 | 单人工具 | 架构过于复杂 |
|
||||
| 云端优先 | 本地为主 | 云功能未实现 |
|
||||
| 智能分类器 | 简单分类工具 | AI 调用复杂 |
|
||||
| 完整应用 | 轻量工具 | 依赖过多 |
|
||||
|
||||
---
|
||||
|
||||
## 重新定义的需求
|
||||
|
||||
### 核心定位
|
||||
|
||||
**CutThenThink** 是一个**轻量级个人截图管理工具**,专注于:
|
||||
1. 快速截图
|
||||
2. 文字识别
|
||||
3. 智能分类
|
||||
4. 本地存储为主,可选云端同步
|
||||
|
||||
### 最小可行产品(MVP)功能
|
||||
|
||||
#### Phase 1: 核心功能(必须)
|
||||
|
||||
1. **截图功能**
|
||||
- 全局快捷键截图
|
||||
- 区域选择截图
|
||||
- 剪贴板图片检测
|
||||
|
||||
2. **OCR 识别**
|
||||
- 本地 OCR 引擎(PaddleOCR 轻量版)
|
||||
- 云端 OCR API 作为可选增强
|
||||
|
||||
3. **AI 分类**
|
||||
- 简单规则分类(基于关键词)
|
||||
- AI API 分类作为可选功能
|
||||
|
||||
4. **本地存储**
|
||||
- SQLite 本地数据库
|
||||
- 图片文件存储
|
||||
- 基础 CRUD 操作
|
||||
|
||||
5. **简单 GUI**
|
||||
- 主窗口
|
||||
- 截图预览
|
||||
- 历史记录浏览
|
||||
|
||||
#### Phase 2: 增强功能(可选)
|
||||
|
||||
1. **云端同步**
|
||||
- 简单的 WebDAV 同步
|
||||
- 或单个云服务商集成
|
||||
|
||||
2. **高级 AI**
|
||||
- OpenAI/Claude API 集成
|
||||
- 本地 Ollama 支持
|
||||
|
||||
3. **批量操作**
|
||||
- 批量导入图片
|
||||
- 批量导出
|
||||
|
||||
#### Phase 3: 优化功能(未来)
|
||||
|
||||
1. 图片标注/编辑
|
||||
2. 高级搜索
|
||||
3. 统计报表
|
||||
4. 多设备同步
|
||||
|
||||
---
|
||||
|
||||
## 技术栈简化建议
|
||||
|
||||
### 当前技术栈问题
|
||||
|
||||
| 组件 | 当前 | 问题 |
|
||||
|------|------|------|
|
||||
| GUI | PyQt6 | 功能过多,配置复杂 |
|
||||
| 数据库 | SQLAlchemy | 过度工程 |
|
||||
| OCR | 云端 API | 无法使用 |
|
||||
| AI | 多个 API | 配置复杂 |
|
||||
|
||||
### 建议的技术栈
|
||||
|
||||
| 组件 | 建议 | 理由 |
|
||||
|------|------|------|
|
||||
| GUI | PyQt6 (简化) | 保持不变,但简化功能 |
|
||||
| 数据库 | SQLite 原生 | 去掉 ORM,简化代码 |
|
||||
| OCR | PaddleOCR 轻量版 | 本地运行,不依赖网络 |
|
||||
| AI | 规则 + 可选 API | 基础功能离线可用 |
|
||||
| 存储 | 文件系统 | 简单直接 |
|
||||
|
||||
---
|
||||
|
||||
## 代码重构建议
|
||||
|
||||
### 1. 简化配置系统
|
||||
|
||||
**当前**:5 大类配置(AI/OCR/云端/界面/高级)
|
||||
|
||||
**建议**:
|
||||
```yaml
|
||||
# 简化配置文件 config.yaml
|
||||
ocr:
|
||||
engine: local # local 或 cloud
|
||||
|
||||
ai:
|
||||
enabled: false # 默认关闭
|
||||
provider: "" # 可选配置
|
||||
|
||||
storage:
|
||||
type: local # local 或 webdav
|
||||
|
||||
hotkeys:
|
||||
screenshot: Ctrl+Shift+A
|
||||
```
|
||||
|
||||
### 2. 实现本地 OCR
|
||||
|
||||
```python
|
||||
# 简化的 OCR 模块
|
||||
from paddleocr import PaddleOCR
|
||||
|
||||
class LocalOCR:
|
||||
def __init__(self):
|
||||
self.ocr = PaddleOCR(use_angle_cls=True, lang='ch')
|
||||
|
||||
def recognize(self, image_path):
|
||||
result = self.ocr.ocr(image_path, cls=True)
|
||||
return self._parse_result(result)
|
||||
```
|
||||
|
||||
### 3. 简化数据模型
|
||||
|
||||
```python
|
||||
# 简化的数据库模型
|
||||
import sqlite3
|
||||
|
||||
class RecordDB:
|
||||
def __init__(self, path):
|
||||
self.conn = sqlite3.connect(path)
|
||||
self._init_table()
|
||||
|
||||
def add(self, image_path, ocr_text, category):
|
||||
# 简单的插入操作
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. 规则优先的 AI 分类
|
||||
|
||||
```python
|
||||
# 简单规则分类
|
||||
class RuleClassifier:
|
||||
RULES = {
|
||||
'TODO': ['待办', '任务', '完成', 'TODO'],
|
||||
'NOTE': ['笔记', '记录', '会议'],
|
||||
# ...
|
||||
}
|
||||
|
||||
def classify(self, text):
|
||||
for category, keywords in self.RULES.items():
|
||||
if any(kw in text for kw in keywords):
|
||||
return category
|
||||
return 'TEXT'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发优先级
|
||||
|
||||
### P0 (立即修复)
|
||||
|
||||
1. **实现本地 OCR** - 这是核心功能,必须可用
|
||||
2. **简化配置系统** - 降低使用门槛
|
||||
3. **基础 GUI 完善** - 确保核心流程可用
|
||||
|
||||
### P1 (短期完成)
|
||||
|
||||
1. 实现规则分类系统
|
||||
2. 完善本地存储功能
|
||||
3. 添加批量操作支持
|
||||
|
||||
### P2 (长期优化)
|
||||
|
||||
1. 云端同步功能
|
||||
2. AI API 集成
|
||||
3. 高级编辑功能
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
### 核心问题
|
||||
|
||||
CutThenThink 项目的主要问题在于**过度设计**:
|
||||
- 功能过多但实现不足
|
||||
- 架构复杂但缺少核心
|
||||
- 配置繁重但难以使用
|
||||
|
||||
### 解决方向
|
||||
|
||||
1. **回归本质**:轻量级截图工具
|
||||
2. **核心优先**:OCR + 分类 + 存储
|
||||
3. **本地为主**:确保离线可用
|
||||
4. **渐进增强**:从简单到复杂
|
||||
|
||||
### 下一步行动
|
||||
|
||||
1. 确认 MVP 功能范围
|
||||
2. 实现 PaddleOCR 本地集成
|
||||
3. 简化配置系统
|
||||
4. 完善基础 GUI 流程
|
||||
5. 测试端到端功能
|
||||
|
||||
---
|
||||
|
||||
*报告生成时间:2025年*
|
||||
*项目路径:CutThenThink*
|
||||
257
docs/需求分析报告_v2.md
Normal file
257
docs/需求分析报告_v2.md
Normal 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 - 简化版*
|
||||
7
pyi_hooks/__init__.py
Normal file
7
pyi_hooks/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
PyInstaller hooks for CutThenThink
|
||||
|
||||
This directory contains custom PyInstaller hooks to handle:
|
||||
- Excluding heavy ML libraries (torch, transformers, etc.)
|
||||
- Runtime-only dependencies like PaddleOCR
|
||||
"""
|
||||
36
pyi_hooks/hook-exclude-ml.py
Normal file
36
pyi_hooks/hook-exclude-ml.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
PyInstaller hook to exclude ML libraries during build
|
||||
|
||||
This prevents PyInstaller from attempting to analyze and bundle
|
||||
heavy ML libraries that are incompatible with Python 3.13 or
|
||||
cause build crashes.
|
||||
|
||||
These libraries will be installed at runtime if needed.
|
||||
"""
|
||||
|
||||
# Collect any hidden imports (empty for this hook)
|
||||
hiddenimports = []
|
||||
|
||||
# Exclude all heavy ML dependencies
|
||||
excludedimports = [
|
||||
'torch',
|
||||
'torch.nn',
|
||||
'torch.utils',
|
||||
'torchvision',
|
||||
'transformers',
|
||||
'tensorflow',
|
||||
'tensorflow.keras',
|
||||
'onnx',
|
||||
'onnxruntime',
|
||||
'sentencepiece',
|
||||
'tokenizers',
|
||||
'diffusers',
|
||||
'accelerate',
|
||||
'datasets',
|
||||
'huggingface_hub',
|
||||
'safetensors',
|
||||
'optimum',
|
||||
'coloredlogs',
|
||||
'docutils',
|
||||
'tqdm',
|
||||
]
|
||||
29
pyi_hooks/hook-paddleocr.py
Normal file
29
pyi_hooks/hook-paddleocr.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
PyInstaller hook for PaddleOCR
|
||||
|
||||
Prevents PyInstaller from trying to bundle PaddleOCR and its dependencies.
|
||||
These will be installed dynamically at runtime.
|
||||
"""
|
||||
|
||||
from PyInstaller.utils.hooks import is_module_satisfies
|
||||
|
||||
# Tell PyInstaller to NOT collect these modules
|
||||
# They will be installed at runtime via ensure_paddleocr()
|
||||
|
||||
hiddenimports = []
|
||||
|
||||
# Explicitly exclude heavy ML dependencies
|
||||
excludedimports = [
|
||||
'torch',
|
||||
'transformers',
|
||||
'tensorflow',
|
||||
'onnx',
|
||||
'onnxruntime',
|
||||
'sentencepiece',
|
||||
'tokenizers',
|
||||
'diffusers',
|
||||
'accelerate',
|
||||
'datasets',
|
||||
'huggingface_hub',
|
||||
'safetensors',
|
||||
]
|
||||
47
pyi_hooks/pyi_rth_ignore_torch.py
Normal file
47
pyi_hooks/pyi_rth_ignore_torch.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
PyInstaller runtime hook to prevent torch import during analysis
|
||||
|
||||
This hook runs before PyInstaller's module analysis phase.
|
||||
It blocks problematic modules from being imported during the build process.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# List of modules to block during PyInstaller analysis
|
||||
BLOCKED_MODULES = [
|
||||
'torch',
|
||||
'transformers',
|
||||
'tensorflow',
|
||||
'onnx',
|
||||
'onnxruntime',
|
||||
'sentencepiece',
|
||||
'tokenizers',
|
||||
'diffusers',
|
||||
'accelerate',
|
||||
'datasets',
|
||||
'huggingface_hub',
|
||||
'safetensors',
|
||||
]
|
||||
|
||||
# Override the import mechanism to block these modules
|
||||
class BlockModuleImport:
|
||||
"""Meta path importer to block specific modules during PyInstaller build"""
|
||||
|
||||
def find_module(self, fullname, path=None):
|
||||
if fullname in BLOCKED_MODULES or any(
|
||||
fullname.startswith(m + '.') for m in BLOCKED_MODULES
|
||||
):
|
||||
# Return self to handle the import (will raise ImportError)
|
||||
return self
|
||||
return None
|
||||
|
||||
def load_module(self, fullname):
|
||||
raise ImportError(
|
||||
f"Module '{fullname}' is excluded from PyInstaller build. "
|
||||
f"It will be installed at runtime if needed."
|
||||
)
|
||||
|
||||
# Install the blocker at the start of PyInstaller analysis
|
||||
if '_MEIPASS' not in sys.__dict__:
|
||||
# Only during build time (not when running the frozen app)
|
||||
sys.meta_path.insert(0, BlockModuleImport())
|
||||
21
requirements-core.txt
Normal file
21
requirements-core.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
# CutThenThink 核心依赖(纯接口版本,无本地ML库)
|
||||
#
|
||||
# 此版本使用云端 API 进行 OCR 和 AI 处理
|
||||
# 不需要安装任何本地机器学习库
|
||||
|
||||
# GUI框架
|
||||
PyQt6>=6.7.0
|
||||
|
||||
# 数据库
|
||||
SQLAlchemy>=2.0.36
|
||||
|
||||
# AI服务(API调用)
|
||||
openai>=1.0.0
|
||||
anthropic>=0.18.0
|
||||
|
||||
# 工具库
|
||||
requests>=2.31.0
|
||||
pyyaml>=6.0.1
|
||||
pillow>=10.0.0
|
||||
pyperclip>=1.8.2
|
||||
numpy>=1.24.0
|
||||
8
requirements-ocr.txt
Normal file
8
requirements-ocr.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
# CutThenThink - 可选 OCR 插件
|
||||
# 安装:pip install -r requirements-ocr.txt
|
||||
|
||||
# 轻量级 OCR 引擎
|
||||
rapidocr>=1.3.0
|
||||
|
||||
# ONNX 运行时
|
||||
onnxruntime>=1.16.0
|
||||
@@ -1,22 +1,17 @@
|
||||
# CutThenThink 项目依赖
|
||||
# CutThenThink - 极简截图上传工具
|
||||
# 核心依赖
|
||||
|
||||
# GUI框架
|
||||
PyQt6==6.6.1
|
||||
PyQt6-WebEngine==6.6.0
|
||||
# GUI 框架
|
||||
PyQt6>=6.7.0
|
||||
|
||||
# 数据库
|
||||
SQLAlchemy==2.0.25
|
||||
# 图片处理
|
||||
Pillow>=10.0.0
|
||||
|
||||
# OCR识别
|
||||
paddleocr>=2.7.0
|
||||
paddlepaddle>=2.6.0
|
||||
|
||||
# AI服务
|
||||
openai>=1.0.0
|
||||
anthropic>=0.18.0
|
||||
|
||||
# 工具库
|
||||
# HTTP 请求
|
||||
requests>=2.31.0
|
||||
pyyaml>=6.0.1
|
||||
pillow>=10.0.0
|
||||
|
||||
# 剪贴板
|
||||
pyperclip>=1.8.2
|
||||
|
||||
# 配置文件
|
||||
pyyaml>=6.0.1
|
||||
|
||||
170
src/config.py
Normal file
170
src/config.py
Normal 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
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
配置管理模块
|
||||
"""
|
||||
|
||||
from src.config.settings import Settings, get_config
|
||||
|
||||
__all__ = ['Settings', 'get_config']
|
||||
@@ -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 模式枚举"""
|
||||
LOCAL = "local" # 本地 PaddleOCR
|
||||
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.LOCAL
|
||||
api_key: str = "" # 云端 OCR API key
|
||||
api_endpoint: str = "" # 云端 OCR endpoint
|
||||
use_gpu: bool = False # 本地 OCR 是否使用 GPU
|
||||
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
|
||||
@@ -1,13 +1,13 @@
|
||||
"""
|
||||
核心功能模块
|
||||
核心功能模块 - 纯云端版本
|
||||
"""
|
||||
|
||||
from src.core.ocr import (
|
||||
# 基础类
|
||||
BaseOCREngine,
|
||||
PaddleOCREngine,
|
||||
CloudOCREngine,
|
||||
OCRFactory,
|
||||
OCRProvider,
|
||||
|
||||
# 结果模型
|
||||
OCRResult,
|
||||
@@ -68,9 +68,9 @@ from src.core.processor import (
|
||||
__all__ = [
|
||||
# OCR 模块
|
||||
'BaseOCREngine',
|
||||
'PaddleOCREngine',
|
||||
'CloudOCREngine',
|
||||
'OCRFactory',
|
||||
'OCRProvider',
|
||||
'OCRResult',
|
||||
'OCRBatchResult',
|
||||
'ImagePreprocessor',
|
||||
|
||||
680
src/core/ai.py
680
src/core/ai.py
@@ -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
234
src/core/database.py
Normal 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
|
||||
637
src/core/ocr.py
637
src/core/ocr.py
@@ -1,637 +0,0 @@
|
||||
"""
|
||||
OCR 模块
|
||||
|
||||
提供文字识别功能,支持:
|
||||
- 本地 PaddleOCR 识别
|
||||
- 云端 OCR API 扩展
|
||||
- 图片预处理增强
|
||||
- 多语言支持(中/英/混合)
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"请安装图像处理库: pip install pillow numpy"
|
||||
)
|
||||
|
||||
try:
|
||||
from paddleocr import PaddleOCR
|
||||
except ImportError:
|
||||
PaddleOCR = None
|
||||
logging.warning("PaddleOCR 未安装,本地 OCR 功能不可用。首次运行时将自动安装。")
|
||||
|
||||
def ensure_paddleocr():
|
||||
"""
|
||||
确保 PaddleOCR 已安装,如果没有则安装
|
||||
首次运行时自动下载安装 OCR 库
|
||||
"""
|
||||
global PaddleOCR
|
||||
if PaddleOCR is None:
|
||||
import subprocess
|
||||
import sys
|
||||
logging.info("正在安装 PaddleOCR...")
|
||||
try:
|
||||
subprocess.check_call([
|
||||
sys.executable, "-m", "pip", "install",
|
||||
"--break-system-packages",
|
||||
"paddleocr"
|
||||
])
|
||||
# 重新导入
|
||||
from paddleocr import PaddleOCR
|
||||
globals()["PaddleOCR"] = PaddleOCR
|
||||
logging.info("PaddleOCR 安装成功!")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"PaddleOCR 安装失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OCRLanguage(str, Enum):
|
||||
"""OCR 支持的语言"""
|
||||
CHINESE = "ch" # 中文
|
||||
ENGLISH = "en" # 英文
|
||||
MIXED = "chinese_chinese" # 中英文混合
|
||||
|
||||
|
||||
@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 enhance_brightness(image: Image.Image, factor: float = 1.1) -> Image.Image:
|
||||
"""
|
||||
调整亮度
|
||||
|
||||
Args:
|
||||
image: PIL Image 对象
|
||||
factor: 亮度因子
|
||||
|
||||
Returns:
|
||||
处理后的图像
|
||||
"""
|
||||
enhancer = ImageEnhance.Brightness(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 binarize(image: Image.Image, threshold: int = 127) -> Image.Image:
|
||||
"""
|
||||
二值化(转换为黑白图像)
|
||||
|
||||
Args:
|
||||
image: PIL Image 对象
|
||||
threshold: 二值化阈值
|
||||
|
||||
Returns:
|
||||
处理后的图像
|
||||
"""
|
||||
# 先转为灰度图
|
||||
gray = image.convert('L')
|
||||
# 二值化
|
||||
binary = gray.point(lambda x: 0 if x < threshold else 255, '1')
|
||||
# 转回 RGB
|
||||
return binary.convert('RGB')
|
||||
|
||||
@staticmethod
|
||||
def preprocess(
|
||||
image: Image.Image,
|
||||
resize: bool = True,
|
||||
enhance_contrast: bool = True,
|
||||
enhance_sharpness: bool = True,
|
||||
denoise: bool = False,
|
||||
binarize: bool = False
|
||||
) -> Image.Image:
|
||||
"""
|
||||
综合预处理(根据指定选项)
|
||||
|
||||
Args:
|
||||
image: PIL Image 对象
|
||||
resize: 是否调整大小
|
||||
enhance_contrast: 是否增强对比度
|
||||
enhance_sharpness: 是否增强锐度
|
||||
denoise: 是否去噪
|
||||
binarize: 是否二值化
|
||||
|
||||
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)
|
||||
|
||||
if binarize:
|
||||
result = ImagePreprocessor.binarize(result)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def preprocess_from_path(
|
||||
image_path: str,
|
||||
**kwargs
|
||||
) -> Image.Image:
|
||||
"""
|
||||
从文件路径加载并预处理图像
|
||||
|
||||
Args:
|
||||
image_path: 图像文件路径
|
||||
**kwargs: preprocess 方法的参数
|
||||
|
||||
Returns:
|
||||
处理后的图像
|
||||
"""
|
||||
image = ImagePreprocessor.load_image(image_path)
|
||||
return ImagePreprocessor.preprocess(image, **kwargs)
|
||||
|
||||
|
||||
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 或 numpy 数组)
|
||||
preprocess: 是否预处理图像
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
OCRBatchResult: 识别结果
|
||||
"""
|
||||
pass
|
||||
|
||||
def _load_image(self, image) -> Image.Image:
|
||||
"""
|
||||
加载图像(支持多种输入格式)
|
||||
|
||||
Args:
|
||||
image: 图像(路径、PIL Image 或 numpy 数组)
|
||||
|
||||
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
|
||||
elif isinstance(image, np.ndarray):
|
||||
return Image.fromarray(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 PaddleOCREngine(BaseOCREngine):
|
||||
"""
|
||||
PaddleOCR 本地识别引擎
|
||||
|
||||
使用 PaddleOCR 进行本地文字识别
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
初始化 PaddleOCR 引擎
|
||||
|
||||
Args:
|
||||
config: 配置字典,支持:
|
||||
- use_gpu: 是否使用 GPU (默认 False)
|
||||
- lang: 语言 (默认 "ch",支持 ch/en/chinese_chinese)
|
||||
- show_log: 是否显示日志 (默认 False)
|
||||
"""
|
||||
super().__init__(config)
|
||||
|
||||
if PaddleOCR is None:
|
||||
raise ImportError(
|
||||
"PaddleOCR 未安装。请运行: pip install paddleocr paddlepaddle"
|
||||
)
|
||||
|
||||
# 解析配置
|
||||
self.use_gpu = self.config.get('use_gpu', False)
|
||||
self.lang = self.config.get('lang', 'ch')
|
||||
self.show_log = self.config.get('show_log', False)
|
||||
|
||||
# 初始化 PaddleOCR
|
||||
logger.info(f"初始化 PaddleOCR (lang={self.lang}, gpu={self.use_gpu})")
|
||||
self.ocr = PaddleOCR(
|
||||
use_angle_cls=True, # 使用方向分类器
|
||||
lang=self.lang,
|
||||
use_gpu=self.use_gpu,
|
||||
show_log=self.show_log
|
||||
)
|
||||
|
||||
def recognize(
|
||||
self,
|
||||
image,
|
||||
preprocess: bool = False,
|
||||
**kwargs
|
||||
) -> OCRBatchResult:
|
||||
"""
|
||||
使用 PaddleOCR 识别图像中的文本
|
||||
|
||||
Args:
|
||||
image: 图像(路径、PIL Image 或 numpy 数组)
|
||||
preprocess: 是否预处理图像
|
||||
**kwargs: 其他参数(未使用)
|
||||
|
||||
Returns:
|
||||
OCRBatchResult: 识别结果
|
||||
"""
|
||||
try:
|
||||
# 加载图像
|
||||
pil_image = self._load_image(image)
|
||||
|
||||
# 预处理(如果启用)
|
||||
if preprocess:
|
||||
pil_image = self.preprocessor.preprocess(pil_image)
|
||||
|
||||
# 转换为 numpy 数组(PaddleOCR 需要)
|
||||
img_array = np.array(pil_image)
|
||||
|
||||
# 执行 OCR
|
||||
result = self.ocr.ocr(img_array, cls=True)
|
||||
|
||||
# 解析结果
|
||||
ocr_results = []
|
||||
full_lines = []
|
||||
|
||||
if result and result[0]:
|
||||
for line_index, line in enumerate(result[0]):
|
||||
if line:
|
||||
# PaddleOCR 返回格式: [[bbox], (text, confidence)]
|
||||
bbox = line[0]
|
||||
text_info = line[1]
|
||||
text = text_info[0]
|
||||
confidence = float(text_info[1])
|
||||
|
||||
ocr_result = OCRResult(
|
||||
text=text,
|
||||
confidence=confidence,
|
||||
bbox=bbox,
|
||||
line_index=line_index
|
||||
)
|
||||
ocr_results.append(ocr_result)
|
||||
full_lines.append(text)
|
||||
|
||||
# 计算平均置信度
|
||||
total_confidence = self._calculate_total_confidence(ocr_results)
|
||||
|
||||
# 拼接完整文本
|
||||
full_text = '\n'.join(full_lines)
|
||||
|
||||
logger.info(f"OCR 识别完成: {len(ocr_results)} 行, 平均置信度 {total_confidence:.2f}")
|
||||
|
||||
return OCRBatchResult(
|
||||
results=ocr_results,
|
||||
full_text=full_text,
|
||||
total_confidence=total_confidence,
|
||||
success=True
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
class CloudOCREngine(BaseOCREngine):
|
||||
"""
|
||||
云端 OCR 引擎(适配器)
|
||||
|
||||
预留接口,用于扩展云端 OCR 服务
|
||||
支持:百度 OCR、腾讯 OCR、阿里云 OCR 等
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
初始化云端 OCR 引擎
|
||||
|
||||
Args:
|
||||
config: 配置字典,支持:
|
||||
- api_endpoint: API 端点
|
||||
- api_key: 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.provider = self.config.get('provider', 'custom')
|
||||
self.timeout = self.config.get('timeout', 30)
|
||||
|
||||
if not self.api_endpoint:
|
||||
logger.warning("云端 OCR: api_endpoint 未配置")
|
||||
|
||||
def recognize(
|
||||
self,
|
||||
image,
|
||||
preprocess: bool = False,
|
||||
**kwargs
|
||||
) -> OCRBatchResult:
|
||||
"""
|
||||
使用云端 API 识别图像中的文本
|
||||
|
||||
Args:
|
||||
image: 图像(路径、PIL Image)
|
||||
preprocess: 是否预处理图像
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
OCRBatchResult: 识别结果
|
||||
"""
|
||||
# 这是一个占位实现
|
||||
# 实际使用时需要根据具体的云端 OCR API 实现
|
||||
logger.warning("云端 OCR 尚未实现,请使用本地 PaddleOCR 或自行实现")
|
||||
|
||||
return OCRBatchResult(
|
||||
results=[],
|
||||
full_text="",
|
||||
total_confidence=0.0,
|
||||
success=False,
|
||||
error_message="云端 OCR 尚未实现"
|
||||
)
|
||||
|
||||
def _send_request(self, image_data: bytes) -> Dict[str, Any]:
|
||||
"""
|
||||
发送 API 请求(待实现)
|
||||
|
||||
Args:
|
||||
image_data: 图像二进制数据
|
||||
|
||||
Returns:
|
||||
API 响应
|
||||
"""
|
||||
raise NotImplementedError("请根据具体云服务 API 实现此方法")
|
||||
|
||||
|
||||
class OCRFactory:
|
||||
"""
|
||||
OCR 引擎工厂
|
||||
|
||||
根据配置创建对应的 OCR 引擎实例
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_engine(
|
||||
mode: str = "local",
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> BaseOCREngine:
|
||||
"""
|
||||
创建 OCR 引擎
|
||||
|
||||
Args:
|
||||
mode: OCR 模式 ("local" 或 "cloud")
|
||||
config: 配置字典
|
||||
|
||||
Returns:
|
||||
BaseOCREngine: OCR 引擎实例
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的 OCR 模式
|
||||
"""
|
||||
if mode == "local":
|
||||
return PaddleOCREngine(config)
|
||||
elif mode == "cloud":
|
||||
return CloudOCREngine(config)
|
||||
else:
|
||||
raise ValueError(f"不支持的 OCR 模式: {mode}")
|
||||
|
||||
|
||||
# 便捷函数
|
||||
def recognize_text(
|
||||
image,
|
||||
mode: str = "local",
|
||||
lang: str = "ch",
|
||||
use_gpu: bool = False,
|
||||
preprocess: bool = False,
|
||||
**kwargs
|
||||
) -> OCRBatchResult:
|
||||
"""
|
||||
快捷识别文本
|
||||
|
||||
Args:
|
||||
image: 图像(路径、PIL Image)
|
||||
mode: OCR 模式 ("local" 或 "cloud")
|
||||
lang: 语言 (ch/en/chinese_chinese)
|
||||
use_gpu: 是否使用 GPU(仅本地模式)
|
||||
preprocess: 是否预处理图像
|
||||
**kwargs: 其他配置
|
||||
|
||||
Returns:
|
||||
OCRBatchResult: 识别结果
|
||||
"""
|
||||
config = {
|
||||
'lang': lang,
|
||||
'use_gpu': use_gpu,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,517 +0,0 @@
|
||||
"""
|
||||
处理流程整合模块
|
||||
|
||||
负责串联 OCR -> AI -> 存储的完整处理流程
|
||||
包括错误处理、进度回调、日志记录等功能
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, Any, List
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
import traceback
|
||||
|
||||
from src.core.ocr import recognize_text, OCRBatchResult, OCRFactory
|
||||
from src.core.ai import classify_text, ClassificationResult, create_classifier_from_config
|
||||
|
||||
# 尝试导入数据库模块(可选)
|
||||
try:
|
||||
from src.models.database import Record, get_db, init_database
|
||||
HAS_DATABASE = True
|
||||
except ImportError:
|
||||
HAS_DATABASE = False
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("数据库模块不可用,保存功能将不可用")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessResult:
|
||||
"""
|
||||
处理结果数据结构
|
||||
|
||||
Attributes:
|
||||
success: 是否处理成功
|
||||
image_path: 图片路径
|
||||
ocr_result: OCR 识别结果
|
||||
ai_result: AI 分类结果
|
||||
record_id: 数据库记录 ID(如果保存成功)
|
||||
error_message: 错误信息(如果失败)
|
||||
process_time: 处理耗时(秒)
|
||||
steps_completed: 已完成的步骤列表
|
||||
"""
|
||||
success: bool
|
||||
image_path: str
|
||||
ocr_result: Optional[OCRBatchResult] = None
|
||||
ai_result: Optional[ClassificationResult] = None
|
||||
record_id: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
process_time: float = 0.0
|
||||
steps_completed: List[str] = field(default_factory=list)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'success': self.success,
|
||||
'image_path': self.image_path,
|
||||
'ocr_text': self.ocr_result.full_text if self.ocr_result else None,
|
||||
'category': self.ai_result.category.value if self.ai_result else None,
|
||||
'title': self.ai_result.title if self.ai_result else None,
|
||||
'content': self.ai_result.content if self.ai_result else None,
|
||||
'tags': self.ai_result.tags if self.ai_result else [],
|
||||
'confidence': self.ai_result.confidence if self.ai_result else None,
|
||||
'record_id': self.record_id,
|
||||
'error_message': self.error_message,
|
||||
'process_time': self.process_time,
|
||||
'steps_completed': self.steps_completed,
|
||||
'warnings': self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class ProcessCallback:
|
||||
"""
|
||||
处理进度回调类
|
||||
|
||||
用于在处理过程中通知 GUI 更新进度
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.on_start: Optional[Callable] = None
|
||||
self.on_ocr_start: Optional[Callable] = None
|
||||
self.on_ocr_complete: Optional[Callable] = None
|
||||
self.on_ai_start: Optional[Callable] = None
|
||||
self.on_ai_complete: Optional[Callable] = None
|
||||
self.on_save_start: Optional[Callable] = None
|
||||
self.on_save_complete: Optional[Callable] = None
|
||||
self.on_error: Optional[Callable] = None
|
||||
self.on_complete: Optional[Callable] = None
|
||||
self.on_progress: Optional[Callable] = None
|
||||
|
||||
def start(self, message: str = "开始处理"):
|
||||
"""处理开始"""
|
||||
logger.info(f"处理开始: {message}")
|
||||
if self.on_start:
|
||||
self.on_start(message)
|
||||
|
||||
def ocr_start(self, message: str = "开始 OCR 识别"):
|
||||
"""OCR 识别开始"""
|
||||
logger.info(message)
|
||||
if self.on_ocr_start:
|
||||
self.on_ocr_start(message)
|
||||
|
||||
def ocr_complete(self, result: OCRBatchResult):
|
||||
"""OCR 识别完成"""
|
||||
logger.info(f"OCR 识别完成: {len(result.results)} 行文本, 置信度 {result.total_confidence:.2f}")
|
||||
if self.on_ocr_complete:
|
||||
self.on_ocr_complete(result)
|
||||
|
||||
def ai_start(self, message: str = "开始 AI 分类"):
|
||||
"""AI 分类开始"""
|
||||
logger.info(message)
|
||||
if self.on_ai_start:
|
||||
self.on_ai_start(message)
|
||||
|
||||
def ai_complete(self, result: ClassificationResult):
|
||||
"""AI 分类完成"""
|
||||
logger.info(f"AI 分类完成: {result.category.value}, 置信度 {result.confidence:.2f}")
|
||||
if self.on_ai_complete:
|
||||
self.on_ai_complete(result)
|
||||
|
||||
def save_start(self, message: str = "开始保存到数据库"):
|
||||
"""保存开始"""
|
||||
logger.info(message)
|
||||
if self.on_save_start:
|
||||
self.on_save_start(message)
|
||||
|
||||
def save_complete(self, record_id: int):
|
||||
"""保存完成"""
|
||||
logger.info(f"保存完成: 记录 ID {record_id}")
|
||||
if self.on_save_complete:
|
||||
self.on_save_complete(record_id)
|
||||
|
||||
def error(self, message: str, exception: Optional[Exception] = None):
|
||||
"""处理出错"""
|
||||
logger.error(f"处理错误: {message}", exc_info=exception is not None)
|
||||
if self.on_error:
|
||||
self.on_error(message, exception)
|
||||
|
||||
def complete(self, result: ProcessResult):
|
||||
"""处理完成"""
|
||||
logger.info(f"处理完成: 成功={result.success}, 耗时={result.process_time:.2f}秒")
|
||||
if self.on_complete:
|
||||
self.on_complete(result)
|
||||
|
||||
def progress(self, step: str, progress: float, message: str = ""):
|
||||
"""
|
||||
进度更新
|
||||
|
||||
Args:
|
||||
step: 当前步骤名称
|
||||
progress: 进度百分比 (0-100)
|
||||
message: 附加信息
|
||||
"""
|
||||
if self.on_progress:
|
||||
self.on_progress(step, progress, message)
|
||||
|
||||
|
||||
class ImageProcessor:
|
||||
"""
|
||||
图片处理器
|
||||
|
||||
负责整合 OCR、AI 分类、数据库存储的完整流程
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ocr_config: Optional[Dict[str, Any]] = None,
|
||||
ai_config: Optional[Any] = None,
|
||||
db_path: Optional[str] = None,
|
||||
callback: Optional[ProcessCallback] = None
|
||||
):
|
||||
"""
|
||||
初始化图片处理器
|
||||
|
||||
Args:
|
||||
ocr_config: OCR 配置字典
|
||||
ai_config: AI 配置对象
|
||||
db_path: 数据库路径
|
||||
callback: 进度回调对象
|
||||
"""
|
||||
self.ocr_config = ocr_config or {}
|
||||
self.ai_config = ai_config
|
||||
self.db_path = db_path
|
||||
self.callback = callback or ProcessCallback()
|
||||
|
||||
# 初始化数据库
|
||||
if db_path and HAS_DATABASE:
|
||||
init_database(db_path)
|
||||
|
||||
# 创建 AI 分类器(延迟初始化)
|
||||
self.ai_classifier = None
|
||||
|
||||
def _get_ai_classifier(self):
|
||||
"""获取 AI 分类器(延迟初始化)"""
|
||||
if self.ai_classifier is None and self.ai_config:
|
||||
try:
|
||||
self.ai_classifier = create_classifier_from_config(self.ai_config)
|
||||
except Exception as e:
|
||||
logger.warning(f"AI 分类器初始化失败: {e}")
|
||||
return self.ai_classifier
|
||||
|
||||
def process_image(
|
||||
self,
|
||||
image_path: str,
|
||||
save_to_db: bool = True,
|
||||
skip_ocr: bool = False,
|
||||
skip_ai: bool = False,
|
||||
ocr_text: Optional[str] = None
|
||||
) -> ProcessResult:
|
||||
"""
|
||||
处理图片:OCR -> AI 分类 -> 保存到数据库
|
||||
|
||||
Args:
|
||||
image_path: 图片文件路径
|
||||
save_to_db: 是否保存到数据库
|
||||
skip_ocr: 是否跳过 OCR(使用提供的 ocr_text)
|
||||
skip_ai: 是否跳过 AI 分类
|
||||
ocr_text: 直接提供的 OCR 文本(当 skip_ocr=True 时使用)
|
||||
|
||||
Returns:
|
||||
ProcessResult: 处理结果
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
steps_completed = []
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
self.callback.start(f"开始处理图片: {Path(image_path).name}")
|
||||
|
||||
# 步骤 1: OCR 识别
|
||||
ocr_result = None
|
||||
final_ocr_text = ocr_text
|
||||
|
||||
if skip_ocr and ocr_text:
|
||||
# 使用提供的 OCR 文本
|
||||
logger.info("跳过 OCR,使用提供的文本")
|
||||
final_ocr_text = ocr_text
|
||||
steps_completed.append("ocr")
|
||||
elif skip_ocr:
|
||||
warnings.append("skip_ocr=True 但未提供 ocr_text,OCR 结果将为空")
|
||||
steps_completed.append("ocr_skipped")
|
||||
else:
|
||||
self.callback.ocr_start()
|
||||
try:
|
||||
ocr_mode = self.ocr_config.get('mode', 'local')
|
||||
ocr_lang = self.ocr_config.get('lang', 'ch')
|
||||
ocr_use_gpu = self.ocr_config.get('use_gpu', False)
|
||||
|
||||
ocr_result = recognize_text(
|
||||
image=image_path,
|
||||
mode=ocr_mode,
|
||||
lang=ocr_lang,
|
||||
use_gpu=ocr_use_gpu,
|
||||
preprocess=False # 暂不启用预处理
|
||||
)
|
||||
|
||||
if not ocr_result.success:
|
||||
warnings.append(f"OCR 识别失败: {ocr_result.error_message}")
|
||||
elif not ocr_result.full_text.strip():
|
||||
warnings.append("OCR 识别结果为空")
|
||||
else:
|
||||
final_ocr_text = ocr_result.full_text
|
||||
steps_completed.append("ocr")
|
||||
self.callback.ocr_complete(ocr_result)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"OCR 识别异常: {str(e)}"
|
||||
warnings.append(error_msg)
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
# 步骤 2: AI 分类
|
||||
ai_result = None
|
||||
|
||||
if skip_ai:
|
||||
logger.info("跳过 AI 分类")
|
||||
steps_completed.append("ai_skipped")
|
||||
elif not final_ocr_text or not final_ocr_text.strip():
|
||||
warnings.append("OCR 文本为空,跳过 AI 分类")
|
||||
steps_completed.append("ai_skipped")
|
||||
else:
|
||||
ai_classifier = self._get_ai_classifier()
|
||||
if ai_classifier is None:
|
||||
warnings.append("AI 分类器未初始化,跳过 AI 分类")
|
||||
steps_completed.append("ai_skipped")
|
||||
else:
|
||||
self.callback.ai_start()
|
||||
try:
|
||||
ai_result = ai_classifier.classify(final_ocr_text)
|
||||
steps_completed.append("ai")
|
||||
self.callback.ai_complete(ai_result)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"AI 分类异常: {str(e)}"
|
||||
warnings.append(error_msg)
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
# 步骤 3: 保存到数据库
|
||||
record_id = None
|
||||
|
||||
if save_to_db:
|
||||
if not HAS_DATABASE:
|
||||
warnings.append("数据库模块不可用,无法保存")
|
||||
else:
|
||||
self.callback.save_start()
|
||||
try:
|
||||
session = get_db()
|
||||
|
||||
# 创建记录
|
||||
record = Record(
|
||||
image_path=image_path,
|
||||
ocr_text=final_ocr_text or "",
|
||||
category=ai_result.category.value if ai_result else "TEXT",
|
||||
ai_result=ai_result.content if ai_result else None,
|
||||
tags=ai_result.tags if ai_result else None,
|
||||
notes=None
|
||||
)
|
||||
|
||||
session.add(record)
|
||||
session.commit()
|
||||
session.refresh(record)
|
||||
|
||||
record_id = record.id
|
||||
steps_completed.append("save")
|
||||
self.callback.save_complete(record_id)
|
||||
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"保存到数据库失败: {str(e)}"
|
||||
warnings.append(error_msg)
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
# 计算处理时间
|
||||
process_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# 判断是否成功
|
||||
success = len(steps_completed) > 0
|
||||
|
||||
# 创建结果
|
||||
result = ProcessResult(
|
||||
success=success,
|
||||
image_path=image_path,
|
||||
ocr_result=ocr_result,
|
||||
ai_result=ai_result,
|
||||
record_id=record_id,
|
||||
process_time=process_time,
|
||||
steps_completed=steps_completed,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
self.callback.complete(result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 捕获未处理的异常
|
||||
error_message = f"处理过程发生异常: {str(e)}\n{traceback.format_exc()}"
|
||||
logger.error(error_message, exc_info=True)
|
||||
self.callback.error(error_message, e)
|
||||
|
||||
process_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
result = ProcessResult(
|
||||
success=False,
|
||||
image_path=image_path,
|
||||
error_message=error_message,
|
||||
process_time=process_time,
|
||||
steps_completed=steps_completed,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
self.callback.complete(result)
|
||||
return result
|
||||
|
||||
def batch_process(
|
||||
self,
|
||||
image_paths: List[str],
|
||||
save_to_db: bool = True,
|
||||
skip_ocr: bool = False,
|
||||
skip_ai: bool = False
|
||||
) -> List[ProcessResult]:
|
||||
"""
|
||||
批量处理图片
|
||||
|
||||
Args:
|
||||
image_paths: 图片路径列表
|
||||
save_to_db: 是否保存到数据库
|
||||
skip_ocr: 是否跳过 OCR
|
||||
skip_ai: 是否跳过 AI 分类
|
||||
|
||||
Returns:
|
||||
处理结果列表
|
||||
"""
|
||||
results = []
|
||||
total = len(image_paths)
|
||||
|
||||
for idx, image_path in enumerate(image_paths):
|
||||
logger.info(f"批量处理进度: {idx + 1}/{total}")
|
||||
|
||||
# 更新进度
|
||||
if self.callback.on_progress:
|
||||
self.callback.progress(
|
||||
step=f"处理图片 {idx + 1}/{total}",
|
||||
progress=(idx / total) * 100,
|
||||
message=f"当前: {Path(image_path).name}"
|
||||
)
|
||||
|
||||
result = self.process_image(
|
||||
image_path=image_path,
|
||||
save_to_db=save_to_db,
|
||||
skip_ocr=skip_ocr,
|
||||
skip_ai=skip_ai
|
||||
)
|
||||
|
||||
results.append(result)
|
||||
|
||||
# 完成进度
|
||||
if self.callback.on_progress:
|
||||
self.callback.progress(
|
||||
step="批量处理完成",
|
||||
progress=100,
|
||||
message=f"共处理 {total} 张图片"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# 便捷函数
|
||||
def process_single_image(
|
||||
image_path: str,
|
||||
ocr_config: Optional[Dict[str, Any]] = None,
|
||||
ai_config: Optional[Any] = None,
|
||||
db_path: Optional[str] = None,
|
||||
callback: Optional[ProcessCallback] = None
|
||||
) -> ProcessResult:
|
||||
"""
|
||||
处理单张图片的便捷函数
|
||||
|
||||
Args:
|
||||
image_path: 图片路径
|
||||
ocr_config: OCR 配置
|
||||
ai_config: AI 配置
|
||||
db_path: 数据库路径
|
||||
callback: 回调对象
|
||||
|
||||
Returns:
|
||||
ProcessResult: 处理结果
|
||||
"""
|
||||
processor = ImageProcessor(
|
||||
ocr_config=ocr_config,
|
||||
ai_config=ai_config,
|
||||
db_path=db_path,
|
||||
callback=callback
|
||||
)
|
||||
|
||||
return processor.process_image(image_path)
|
||||
|
||||
|
||||
def create_markdown_result(ai_result: ClassificationResult, ocr_text: str = "") -> str:
|
||||
"""
|
||||
创建 Markdown 格式的结果
|
||||
|
||||
Args:
|
||||
ai_result: AI 分类结果
|
||||
ocr_text: OCR 原始文本
|
||||
|
||||
Returns:
|
||||
Markdown 格式的字符串
|
||||
"""
|
||||
if not ai_result:
|
||||
return f"# 处理结果\n\n## OCR 文本\n\n{ocr_text}"
|
||||
|
||||
category_emoji = {
|
||||
"TODO": "✅",
|
||||
"NOTE": "📝",
|
||||
"IDEA": "💡",
|
||||
"REF": "📚",
|
||||
"FUNNY": "😄",
|
||||
"TEXT": "📄"
|
||||
}
|
||||
|
||||
emoji = category_emoji.get(ai_result.category.value, "📄")
|
||||
|
||||
markdown = f"""# {emoji} {ai_result.title}
|
||||
|
||||
**分类**: {ai_result.category.value} | **置信度**: {ai_result.confidence:.1%}
|
||||
|
||||
---
|
||||
|
||||
{ai_result.content}
|
||||
|
||||
---
|
||||
|
||||
**标签**: {', '.join(ai_result.tags) if ai_result.tags else '无'}
|
||||
"""
|
||||
|
||||
return markdown
|
||||
|
||||
|
||||
def copy_to_clipboard(text: str) -> bool:
|
||||
"""
|
||||
复制文本到剪贴板
|
||||
|
||||
Args:
|
||||
text: 要复制的文本
|
||||
|
||||
Returns:
|
||||
是否复制成功
|
||||
"""
|
||||
try:
|
||||
from src.utils.clipboard import copy_to_clipboard as utils_copy_to_clipboard
|
||||
return utils_copy_to_clipboard(text)
|
||||
except Exception as e:
|
||||
logger.error(f"复制到剪贴板失败: {e}")
|
||||
return False
|
||||
234
src/core/screenshot.py
Normal file
234
src/core/screenshot.py
Normal 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}")
|
||||
@@ -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
182
src/core/uploader.py
Normal 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
@@ -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',
|
||||
])
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -1,553 +0,0 @@
|
||||
"""
|
||||
错误提示和日志系统的 GUI 集成
|
||||
|
||||
提供统一的消息处理和错误显示功能
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from typing import Optional, Callable, List, Dict, Any
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from src.utils.logger import get_logger, LogCapture
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LogLevel:
|
||||
"""日志级别"""
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
|
||||
class MessageHandler:
|
||||
"""
|
||||
消息处理器
|
||||
|
||||
负责显示各种类型的消息和错误
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""
|
||||
初始化消息处理器
|
||||
|
||||
Args:
|
||||
parent: 父窗口
|
||||
"""
|
||||
self.parent = parent
|
||||
self.log_capture: Optional[LogCapture] = 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 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 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 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 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 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 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
|
||||
|
||||
|
||||
class ErrorLogViewer(tk.Toplevel):
|
||||
"""
|
||||
错误日志查看器
|
||||
|
||||
显示详细的错误和日志信息
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
title: str = "错误日志",
|
||||
errors: Optional[List[Dict[str, Any]]] = None
|
||||
):
|
||||
"""
|
||||
初始化错误日志查看器
|
||||
|
||||
Args:
|
||||
parent: 父窗口
|
||||
title: 窗口标题
|
||||
errors: 错误列表
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.title(title)
|
||||
self.geometry("800x600")
|
||||
|
||||
self.errors = errors or []
|
||||
|
||||
self._create_ui()
|
||||
self._load_errors()
|
||||
|
||||
def _create_ui(self):
|
||||
"""创建 UI"""
|
||||
# 工具栏
|
||||
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)
|
||||
|
||||
# 配置标签
|
||||
self.text_widget.tag_config("timestamp", foreground="#7f8c8d")
|
||||
self.text_widget.tag_config("ERROR", foreground="#e74c3c", font=("Consolas", 9, "bold"))
|
||||
self.text_widget.tag_config("WARNING", foreground="#f39c12")
|
||||
self.text_widget.tag_config("INFO", foreground="#3498db")
|
||||
self.text_widget.tag_config("DEBUG", foreground="#95a5a6")
|
||||
|
||||
# 状态栏
|
||||
self.status_label = ttk.Label(self, text="就绪", relief=tk.SUNKEN, anchor=tk.W)
|
||||
self.status_label.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=5)
|
||||
|
||||
def _load_errors(self):
|
||||
"""加载错误"""
|
||||
level_filter = self.level_var.get()
|
||||
|
||||
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)
|
||||
|
||||
# 插入内容
|
||||
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")
|
||||
|
||||
self.status_label.config(text=f"显示 {count} 条日志")
|
||||
|
||||
def _on_filter_change(self, event=None):
|
||||
"""过滤器改变"""
|
||||
self._load_errors()
|
||||
|
||||
def _on_clear(self):
|
||||
"""清空日志"""
|
||||
self.errors.clear()
|
||||
self.text_widget.delete("1.0", tk.END)
|
||||
self.status_label.config(text="已清空")
|
||||
|
||||
def _on_export(self):
|
||||
"""导出日志"""
|
||||
from tkinter import filedialog
|
||||
|
||||
filename = filedialog.asksaveasfilename(
|
||||
parent=self,
|
||||
title="导出日志",
|
||||
defaultextension=".txt",
|
||||
filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")]
|
||||
)
|
||||
|
||||
if filename:
|
||||
try:
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
f.write(self.text_widget.get("1.0", tk.END))
|
||||
messagebox.showinfo("导出成功", f"日志已导出到:\n{filename}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("导出失败", 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()
|
||||
|
||||
|
||||
class ProgressDialog(tk.Toplevel):
|
||||
"""
|
||||
进度对话框
|
||||
|
||||
显示处理进度和状态
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
title: str = "处理中",
|
||||
message: str = "请稍候...",
|
||||
cancelable: bool = False,
|
||||
on_cancel: Optional[Callable] = None
|
||||
):
|
||||
"""
|
||||
初始化进度对话框
|
||||
|
||||
Args:
|
||||
parent: 父窗口
|
||||
title: 标题
|
||||
message: 消息
|
||||
cancelable: 是否可取消
|
||||
on_cancel: 取消回调
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.title(title)
|
||||
self.geometry("400x150")
|
||||
self.resizable(False, False)
|
||||
|
||||
# 设置为模态对话框
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
|
||||
self.on_cancel_callback = on_cancel
|
||||
self.cancelled = False
|
||||
|
||||
self._create_ui(message, cancelable)
|
||||
|
||||
# 居中显示
|
||||
self.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2
|
||||
y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2
|
||||
self.geometry(f"+{x}+{y}")
|
||||
|
||||
def _create_ui(self, message: str, cancelable: bool):
|
||||
"""创建 UI"""
|
||||
# 主容器
|
||||
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:
|
||||
self.cancel_button = ttk.Button(
|
||||
main_frame,
|
||||
text="取消",
|
||||
command=self._on_cancel
|
||||
)
|
||||
self.cancel_button.pack(side=tk.TOP)
|
||||
|
||||
def set_message(self, message: str):
|
||||
"""
|
||||
设置消息
|
||||
|
||||
Args:
|
||||
message: 消息内容
|
||||
"""
|
||||
self.message_label.config(text=message)
|
||||
|
||||
def set_detail(self, detail: str):
|
||||
"""
|
||||
设置详细信息
|
||||
|
||||
Args:
|
||||
detail: 详细信息
|
||||
"""
|
||||
self.detail_label.config(text=detail)
|
||||
|
||||
def set_progress(self, value: float, maximum: float = 100):
|
||||
"""
|
||||
设置进度值
|
||||
|
||||
Args:
|
||||
value: 当前进度值
|
||||
maximum: 最大值
|
||||
"""
|
||||
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()
|
||||
self.destroy()
|
||||
|
||||
def is_cancelled(self) -> bool:
|
||||
"""
|
||||
检查是否已取消
|
||||
|
||||
Returns:
|
||||
是否已取消
|
||||
"""
|
||||
return self.cancelled
|
||||
|
||||
def close(self):
|
||||
"""关闭对话框"""
|
||||
self.progress_bar.stop()
|
||||
self.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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -1,405 +0,0 @@
|
||||
"""
|
||||
结果展示组件
|
||||
|
||||
用于展示处理结果,包括:
|
||||
- OCR 文本展示
|
||||
- AI 处理结果展示(Markdown 格式)
|
||||
- 一键复制功能
|
||||
- 日志查看
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, scrolledtext, messagebox
|
||||
from typing import Optional, Callable, Dict, Any
|
||||
import logging
|
||||
|
||||
try:
|
||||
from tkhtmlview import HTMLLabel
|
||||
HAS_HTMLVIEW = True
|
||||
except ImportError:
|
||||
HAS_HTMLVIEW = False
|
||||
|
||||
from src.core.processor import ProcessResult, create_markdown_result, copy_to_clipboard
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResultWidget(ttk.Frame):
|
||||
"""
|
||||
结果展示组件
|
||||
|
||||
显示处理结果,支持 Markdown 渲染和一键复制
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# 标记当前是否显示 Markdown
|
||||
self._showing_markdown = False
|
||||
|
||||
self._create_ui()
|
||||
|
||||
def _create_ui(self):
|
||||
"""创建 UI"""
|
||||
# 顶部工具栏
|
||||
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.display_mode = tk.StringVar(value="markdown")
|
||||
mode_frame = ttk.Frame(toolbar)
|
||||
mode_frame.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
ttk.Radiobutton(
|
||||
mode_frame,
|
||||
text="Markdown",
|
||||
variable=self.display_mode,
|
||||
value="markdown",
|
||||
command=self._on_display_mode_change
|
||||
).pack(side=tk.LEFT, padx=2)
|
||||
|
||||
ttk.Radiobutton(
|
||||
mode_frame,
|
||||
text="原始文本",
|
||||
variable=self.display_mode,
|
||||
value="raw",
|
||||
command=self._on_display_mode_change
|
||||
).pack(side=tk.LEFT, padx=2)
|
||||
|
||||
# 右侧按钮
|
||||
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)
|
||||
|
||||
self.copy_button = ttk.Button(
|
||||
toolbar,
|
||||
text="📋 复制",
|
||||
command=self._on_copy
|
||||
)
|
||||
self.copy_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.clear_button = ttk.Button(
|
||||
toolbar,
|
||||
text="清空",
|
||||
command=self._on_clear
|
||||
)
|
||||
self.clear_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# 主内容区域(使用 Notebook 实现分页)
|
||||
self.notebook = ttk.Notebook(self)
|
||||
self.notebook.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
# 结果页面
|
||||
self.result_frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(self.result_frame, text="处理结果")
|
||||
|
||||
# 日志页面
|
||||
self.log_frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(self.log_frame, text="日志")
|
||||
|
||||
# 创建结果内容区域
|
||||
self._create_result_content()
|
||||
|
||||
# 创建日志区域
|
||||
self._create_log_content()
|
||||
|
||||
# 底部状态栏
|
||||
status_bar = ttk.Frame(self)
|
||||
status_bar.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=5)
|
||||
|
||||
self.status_label = ttk.Label(status_bar, text="就绪", relief=tk.SUNKEN, anchor=tk.W)
|
||||
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
|
||||
def _create_result_content(self):
|
||||
"""创建结果内容区域"""
|
||||
# 结果展示区域
|
||||
content_frame = ttk.Frame(self.result_frame)
|
||||
content_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
# 使用文本控件显示内容
|
||||
self.result_text = scrolledtext.ScrolledText(
|
||||
content_frame,
|
||||
wrap=tk.WORD,
|
||||
font=("Consolas", 10),
|
||||
state=tk.DISABLED
|
||||
)
|
||||
self.result_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# 滚动条
|
||||
scrollbar = ttk.Scrollbar(content_frame, orient=tk.VERTICAL, command=self.result_text.yview)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.result_text.config(yscrollcommand=scrollbar.set)
|
||||
|
||||
# 配置标签样式
|
||||
self.result_text.tag_config("header", font=("Consolas", 12, "bold"), foreground="#2c3e50")
|
||||
self.result_text.tag_config("bold", font=("Consolas", 10, "bold"))
|
||||
self.result_text.tag_config("info", foreground="#3498db")
|
||||
self.result_text.tag_config("success", foreground="#27ae60")
|
||||
self.result_text.tag_config("warning", foreground="#f39c12")
|
||||
self.result_text.tag_config("error", foreground="#e74c3c")
|
||||
self.result_text.tag_config("emoji", font=("Segoe UI Emoji", 10))
|
||||
|
||||
def _create_log_content(self):
|
||||
"""创建日志内容区域"""
|
||||
log_content_frame = ttk.Frame(self.log_frame)
|
||||
log_content_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
# 日志级别过滤
|
||||
filter_frame = ttk.Frame(log_content_frame)
|
||||
filter_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5))
|
||||
|
||||
ttk.Label(filter_frame, text="日志级别:").pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.log_level_var = tk.StringVar(value="INFO")
|
||||
level_combo = ttk.Combobox(
|
||||
filter_frame,
|
||||
textvariable=self.log_level_var,
|
||||
values=["ALL", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
width=10,
|
||||
state=tk.READONLY
|
||||
)
|
||||
level_combo.pack(side=tk.LEFT, padx=5)
|
||||
level_combo.bind("<<ComboboxSelected>>", self._on_log_level_change)
|
||||
|
||||
ttk.Button(filter_frame, text="清空日志", command=self._on_clear_log).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# 日志文本区域
|
||||
self.log_text = scrolledtext.ScrolledText(
|
||||
log_content_frame,
|
||||
wrap=tk.WORD,
|
||||
font=("Consolas", 9),
|
||||
state=tk.DISABLED
|
||||
)
|
||||
self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# 配置日志标签
|
||||
self.log_text.tag_config("DEBUG", foreground="#95a5a6")
|
||||
self.log_text.tag_config("INFO", foreground="#3498db")
|
||||
self.log_text.tag_config("WARNING", foreground="#f39c12")
|
||||
self.log_text.tag_config("ERROR", foreground="#e74c3c")
|
||||
self.log_text.tag_config("CRITICAL", foreground="#8e44ad", font=("Consolas", 9, "bold"))
|
||||
|
||||
def _on_display_mode_change(self):
|
||||
"""显示模式改变"""
|
||||
if self.current_result:
|
||||
self._update_result_content()
|
||||
|
||||
def _on_copy(self):
|
||||
"""复制按钮点击"""
|
||||
content = self.result_text.get("1.0", tk.END).strip()
|
||||
|
||||
if not content:
|
||||
messagebox.showinfo("提示", "没有可复制的内容")
|
||||
return
|
||||
|
||||
success = copy_to_clipboard(content)
|
||||
|
||||
if success:
|
||||
self._update_status("已复制到剪贴板")
|
||||
if self.copy_callback:
|
||||
self.copy_callback(content)
|
||||
else:
|
||||
messagebox.showerror("错误", "复制失败,请检查是否安装了 pyperclip")
|
||||
|
||||
def _on_clear(self):
|
||||
"""清空按钮点击"""
|
||||
self.result_text.config(state=tk.NORMAL)
|
||||
self.result_text.delete("1.0", tk.END)
|
||||
self.result_text.config(state=tk.DISABLED)
|
||||
self.current_result = None
|
||||
self._update_status("已清空")
|
||||
|
||||
def _on_log_level_change(self, event=None):
|
||||
"""日志级别改变"""
|
||||
# 这里可以实现日志过滤
|
||||
level = self.log_level_var.get()
|
||||
self._update_status(f"日志级别: {level}")
|
||||
|
||||
def _on_clear_log(self):
|
||||
"""清空日志"""
|
||||
self.log_text.config(state=tk.NORMAL)
|
||||
self.log_text.delete("1.0", tk.END)
|
||||
self.log_text.config(state=tk.DISABLED)
|
||||
|
||||
def _update_result_content(self):
|
||||
"""更新结果内容"""
|
||||
if not self.current_result:
|
||||
return
|
||||
|
||||
mode = self.display_mode.get()
|
||||
|
||||
if mode == "markdown":
|
||||
content = self._get_markdown_content()
|
||||
else:
|
||||
content = self._get_raw_content()
|
||||
|
||||
self.result_text.config(state=tk.NORMAL)
|
||||
self.result_text.delete("1.0", tk.END)
|
||||
self.result_text.insert("1.0", content)
|
||||
self.result_text.config(state=tk.DISABLED)
|
||||
|
||||
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):
|
||||
"""更新状态栏"""
|
||||
self.status_label.config(text=message)
|
||||
|
||||
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):
|
||||
"""
|
||||
添加日志
|
||||
|
||||
Args:
|
||||
level: 日志级别
|
||||
message: 日志消息
|
||||
"""
|
||||
self.log_text.config(state=tk.NORMAL)
|
||||
|
||||
# 添加时间戳
|
||||
from datetime import datetime
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
log_entry = f"[{timestamp}] [{level}] {message}\n"
|
||||
|
||||
self.log_text.insert(tk.END, log_entry, level)
|
||||
self.log_text.see(tk.END)
|
||||
|
||||
self.log_text.config(state=tk.DISABLED)
|
||||
|
||||
def get_content(self) -> str:
|
||||
"""获取当前显示的内容"""
|
||||
return self.result_text.get("1.0", tk.END).strip()
|
||||
|
||||
def clear(self):
|
||||
"""清空显示"""
|
||||
self._on_clear()
|
||||
self._on_clear_log()
|
||||
|
||||
|
||||
class QuickResultDialog(tk.Toplevel):
|
||||
"""
|
||||
快速结果显示对话框
|
||||
|
||||
用于快速显示处理结果,不集成到主界面
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
result: ProcessResult,
|
||||
on_close: Optional[Callable] = None
|
||||
):
|
||||
"""
|
||||
初始化对话框
|
||||
|
||||
Args:
|
||||
parent: 父窗口
|
||||
result: 处理结果
|
||||
on_close: 关闭回调
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.result = result
|
||||
self.on_close = on_close
|
||||
|
||||
self.title("处理结果")
|
||||
self.geometry("600x400")
|
||||
|
||||
# 创建组件
|
||||
self.result_widget = ResultWidget(self)
|
||||
self.result_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
||||
|
||||
# 显示结果
|
||||
self.result_widget.set_result(result)
|
||||
|
||||
# 底部按钮
|
||||
button_frame = ttk.Frame(self)
|
||||
button_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Button(button_frame, text="关闭", command=self._on_close).pack(side=tk.RIGHT)
|
||||
|
||||
# 绑定关闭事件
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
def _on_close(self):
|
||||
"""关闭对话框"""
|
||||
if self.on_close:
|
||||
self.on_close()
|
||||
self.destroy()
|
||||
@@ -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()
|
||||
27
src/main.py
27
src/main.py
@@ -1,18 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CutThenThink 应用入口
|
||||
CutThenThink - 极简截图上传工具
|
||||
|
||||
截图 → OCR解析 → AI理解并分类 → 形成备注和执行计划
|
||||
截图 → 上传 → 分类浏览
|
||||
|
||||
核心功能:
|
||||
- 截图(全屏/区域)
|
||||
- 上传到云端
|
||||
- 历史记录管理
|
||||
- 可选 OCR 文字识别
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
def setup_path():
|
||||
"""设置Python路径,兼容开发和打包环境"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# PyInstaller打包后的环境
|
||||
# 在打包环境中,src目录会被解压到sys._MEIPASS
|
||||
base_path = sys._MEIPASS
|
||||
src_path = os.path.join(base_path, 'src')
|
||||
if os.path.exists(src_path):
|
||||
@@ -24,9 +30,22 @@ def setup_path():
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
|
||||
setup_path()
|
||||
|
||||
from 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__":
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
数据模型
|
||||
"""
|
||||
|
||||
from src.models.database import (
|
||||
Base,
|
||||
Record,
|
||||
RecordCategory,
|
||||
DatabaseManager,
|
||||
db_manager,
|
||||
init_database,
|
||||
get_db,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Base',
|
||||
'Record',
|
||||
'RecordCategory',
|
||||
'DatabaseManager',
|
||||
'db_manager',
|
||||
'init_database',
|
||||
'get_db',
|
||||
]
|
||||
@@ -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
6
src/plugins/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
可选插件模块
|
||||
"""
|
||||
from .ocr import get_ocr_plugin, OCRPlugin
|
||||
|
||||
__all__ = ['get_ocr_plugin', 'OCRPlugin']
|
||||
105
src/plugins/ocr.py
Normal file
105
src/plugins/ocr.py
Normal 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}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user