Compare commits
7 Commits
bb27db586d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d4cd9356e | ||
|
|
0ce1d71a90 | ||
|
|
e853161975 | ||
|
|
a5e50876a0 | ||
|
|
313e1f40d8 | ||
|
|
6fc126b0fe | ||
|
|
4589289635 |
@@ -1,20 +1,47 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CutThenThink - Windows 打包配置
|
||||||
|
|
||||||
|
极简截图上传工具的 PyInstaller 配置
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from PyInstaller.building.build_main import Analysis, EXE, COLLECT, PYZ
|
||||||
|
from PyInstaller.building.api import PKG
|
||||||
|
|
||||||
|
# 项目信息
|
||||||
|
APP_NAME = 'CutThenThink'
|
||||||
|
APP_VERSION = '2.0.0'
|
||||||
|
|
||||||
|
# 分析配置
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['src/main.py'],
|
['src/main.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[('src', 'src')],
|
datas=[
|
||||||
hiddenimports=['PyQt6.QtCore', 'PyQt6.QtGui', 'PyQt6.QtWidgets', 'sqlalchemy'],
|
# 包含 README 作为说明
|
||||||
|
('README.md', '.'),
|
||||||
|
],
|
||||||
|
hiddenimports=[
|
||||||
|
'PyQt6.QtCore',
|
||||||
|
'PyQt6.QtGui',
|
||||||
|
'PyQt6.QtWidgets',
|
||||||
|
'yaml',
|
||||||
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[
|
||||||
|
# 排除测试相关
|
||||||
|
'test',
|
||||||
|
'tests',
|
||||||
|
'pytest',
|
||||||
|
],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
optimize=0,
|
optimize=0,
|
||||||
)
|
)
|
||||||
pyz = PYZ(a.pure)
|
|
||||||
|
# 打包配置
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
|
||||||
|
|
||||||
exe = EXE(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
@@ -22,17 +49,42 @@ exe = EXE(
|
|||||||
a.binaries,
|
a.binaries,
|
||||||
a.datas,
|
a.datas,
|
||||||
[],
|
[],
|
||||||
name='CutThenThink',
|
exclude_binaries=True,
|
||||||
|
name=APP_NAME,
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=True, # 使用 UPX 压缩
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=False,
|
console=False, # 无控制台窗口
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
argv_emulation=False,
|
argv_emulation=False,
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
|
icon=None, # TODO: 添加图标路径
|
||||||
|
)
|
||||||
|
|
||||||
|
# 收集所有文件
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name=APP_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建 MSI 安装包(Windows)
|
||||||
|
pkg = PKG(
|
||||||
|
coll,
|
||||||
|
name=APP_NAME,
|
||||||
|
version=APP_VERSION,
|
||||||
|
description='极简截图上传工具',
|
||||||
|
author='CutThenThink',
|
||||||
|
keywords=['screenshot', 'upload', 'OCR'],
|
||||||
|
license='MIT',
|
||||||
)
|
)
|
||||||
|
|||||||
164
README.md
164
README.md
@@ -1,125 +1,113 @@
|
|||||||
# CutThenThink
|
# CutThenThink
|
||||||
|
|
||||||
智能截图OCR与AI分析工具
|
**极简截图上传工具**
|
||||||
|
|
||||||
## 项目简介
|
## 项目简介
|
||||||
|
|
||||||
CutThenThink 是一款基于 PyQt6 的桌面应用程序,集成了OCR文字识别和AI智能分析功能。用户可以通过截图、选择区域,然后使用OCR提取文字,并利用多种AI模型进行智能分析和处理。
|
CutThenThink 是一个轻量级的桌面截图工具,专注于:
|
||||||
|
- 📷 快速截图(全屏/区域)
|
||||||
|
- ☁️ 云端上传(支持多种服务)
|
||||||
|
- 📁 历史记录管理
|
||||||
|
- 🔍 可选 OCR 文字识别
|
||||||
|
|
||||||
## 主要功能
|
## 特点
|
||||||
|
|
||||||
- **智能截图**: 支持多种方式截图(矩形选择、窗口选择、全屏等)
|
- **轻量级**:核心依赖仅 ~50MB
|
||||||
- **OCR识别**: 基于PaddleOCR的高精度文字识别
|
- **可选 OCR**:RapidOCR 插件,按需安装
|
||||||
- **AI分析**: 支持多种AI模型(OpenAI GPT、Anthropic Claude等)
|
- **无重型依赖**:移除了 torch、transformers、paddleocr
|
||||||
- **内容编辑**: 内置编辑器,支持图片标注和文字编辑
|
- **简单配置**:YAML 单文件配置
|
||||||
- **历史记录**: 本地数据库保存所有截图和分析记录
|
- **跨平台**:支持 Windows、macOS、Linux
|
||||||
- **快捷操作**: 全局快捷键支持,快速截图和分析
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **GUI框架**: PyQt6 6.6.1
|
|
||||||
- **数据库**: SQLAlchemy 2.0.25
|
|
||||||
- **OCR引擎**: PaddleOCR 2.7.0.3
|
|
||||||
- **AI模型**: OpenAI API、Anthropic API
|
|
||||||
- **图像处理**: Pillow 10.0.0
|
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
### 环境要求
|
### 基础安装
|
||||||
|
|
||||||
- Python 3.8+
|
|
||||||
- 操作系统: Windows / macOS / Linux
|
|
||||||
|
|
||||||
### 安装步骤
|
|
||||||
|
|
||||||
1. 克隆项目
|
|
||||||
```bash
|
|
||||||
git clone <repository_url>
|
|
||||||
cd CutThenThink
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 创建虚拟环境
|
|
||||||
```bash
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # Linux/macOS
|
|
||||||
# 或
|
|
||||||
venv\Scripts\activate # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 安装依赖
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
python src/main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 配置AI服务
|
### 可选:安装 OCR 支持
|
||||||
|
|
||||||
创建配置文件 `config.yaml`:
|
```bash
|
||||||
```yaml
|
pip install -r requirements-ocr.txt
|
||||||
ai:
|
|
||||||
provider: "openai" # 或 "anthropic"
|
|
||||||
openai:
|
|
||||||
api_key: "your-openai-api-key"
|
|
||||||
model: "gpt-4"
|
|
||||||
anthropic:
|
|
||||||
api_key: "your-anthropic-api-key"
|
|
||||||
model: "claude-3-sonnet-20240229"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
|
|
||||||
启动应用:
|
### 快捷键
|
||||||
```bash
|
|
||||||
python src/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
默认快捷键:
|
| 快捷键 | 功能 |
|
||||||
- `Ctrl+Shift+A`: 截图并分析
|
|--------|------|
|
||||||
- `Ctrl+Shift+S`: 仅截图
|
| `Ctrl+Shift+A` | 全屏截图 |
|
||||||
- `Ctrl+Shift+H`: 打开历史记录
|
| `Ctrl+Shift+R` | 区域截图 |
|
||||||
- `Esc`: 取消截图
|
| `Ctrl+Shift+U` | 上传最后截图 |
|
||||||
|
| `Esc` | 退出 |
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
配置文件位于 `~/.cutthenthink/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
upload:
|
||||||
|
provider: custom # custom, telegraph, imgur
|
||||||
|
endpoint: https://...
|
||||||
|
api_key: your-key
|
||||||
|
auto_copy: true
|
||||||
|
|
||||||
|
screenshot:
|
||||||
|
format: png # png, jpg, webp
|
||||||
|
save_path: ~/Pictures/Screenshots
|
||||||
|
|
||||||
|
hotkeys:
|
||||||
|
capture: Ctrl+Shift+A
|
||||||
|
region: Ctrl+Shift+R
|
||||||
|
upload: Ctrl+Shift+U
|
||||||
|
|
||||||
|
ocr:
|
||||||
|
enabled: false # 是否启用 OCR
|
||||||
|
auto_copy: false # 识别后自动复制
|
||||||
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
CutThenThink/
|
CutThenThink/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── gui/ # GUI组件
|
│ ├── main.py # 入口
|
||||||
│ │ ├── widgets/ # 自定义控件
|
│ ├── config.py # 简化配置
|
||||||
│ │ └── styles/ # 样式文件
|
│ ├── core/
|
||||||
│ ├── core/ # 核心功能
|
│ │ ├── database.py # SQLite 存储
|
||||||
│ ├── models/ # 数据模型
|
│ │ ├── screenshot.py # 截图功能
|
||||||
│ ├── config/ # 配置管理
|
│ │ └── uploader.py # 上传功能
|
||||||
|
│ ├── gui/
|
||||||
|
│ │ └── main_window.py # 主窗口
|
||||||
|
│ ├── plugins/ # 可选插件
|
||||||
|
│ │ └── ocr.py # RapidOCR 插件
|
||||||
│ └── utils/ # 工具函数
|
│ └── utils/ # 工具函数
|
||||||
├── data/ # 数据目录
|
├── requirements.txt # 核心依赖
|
||||||
│ ├── images/ # 截图存储
|
├── requirements-ocr.txt # 可选 OCR
|
||||||
│ └── database/ # 数据库文件
|
└── config.yaml # 配置文件
|
||||||
├── requirements.txt # 项目依赖
|
|
||||||
├── .gitignore # Git忽略文件
|
|
||||||
└── README.md # 项目说明
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发计划
|
## 开发
|
||||||
|
|
||||||
- [x] 项目初始化
|
```bash
|
||||||
- [ ] 基础GUI框架搭建
|
# 安装开发依赖
|
||||||
- [ ] 截图功能实现
|
pip install -r requirements.txt
|
||||||
- [ ] OCR识别集成
|
|
||||||
- [ ] AI分析功能
|
|
||||||
- [ ] 数据库存储
|
|
||||||
- [ ] 历史记录管理
|
|
||||||
- [ ] 配置系统
|
|
||||||
- [ ] 快捷键支持
|
|
||||||
- [ ] 打包发布
|
|
||||||
|
|
||||||
## 贡献指南
|
# 运行
|
||||||
|
python src/main.py
|
||||||
|
```
|
||||||
|
|
||||||
欢迎提交Issue和Pull Request!
|
## 构建
|
||||||
|
|
||||||
|
使用 PyInstaller 打包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pyinstaller CutThenThink.spec
|
||||||
|
```
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
## 联系方式
|
|
||||||
|
|
||||||
- 项目地址: [GitHub Repository]
|
|
||||||
- 问题反馈: [Issues]
|
|
||||||
|
|||||||
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
|
||||||
110
build.bat
110
build.bat
@@ -1,90 +1,58 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM ================================
|
REM CutThenThink 极简版本 Windows 打包脚本
|
||||||
REM CutThenThink Windows Build Script
|
REM 使用 UTF-8 编码避免乱码
|
||||||
REM ================================
|
|
||||||
|
|
||||||
REM Change to project directory
|
chcp 65001 >nul
|
||||||
cd /d "%~dp0"
|
|
||||||
|
|
||||||
REM Check Python
|
setlocal enabledelayedexpansion
|
||||||
echo Checking Python...
|
|
||||||
python --version 2>nul
|
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 (
|
if errorlevel 1 (
|
||||||
echo Python not found. Please install Python 3.8+
|
echo 警告: pip install 失败,请检查网络连接
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
REM Get Python version
|
|
||||||
for /f "tokens=2" %%i in ('python --version 2^>^&1') do set PYVER=%%i
|
|
||||||
echo Detected Python %PYVER%
|
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo 1/5. Installing PyInstaller...
|
echo [3/4] 构建可执行文件...
|
||||||
python -m pip install --user pyinstaller 2>nul
|
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo 2/5. Installing dependencies (compatible with Python 3.13)...
|
python -m PyInstaller CutThenThink.spec --clean
|
||||||
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=sqlalchemy.orm ^
|
|
||||||
--hidden-import=PIL ^
|
|
||||||
--hidden-import=PIL.Image ^
|
|
||||||
--hidden-import=PIL.ImageEnhance ^
|
|
||||||
--hidden-import=PIL.ImageFilter ^
|
|
||||||
--hidden-import=numpy ^
|
|
||||||
--hidden-import=pyperclip ^
|
|
||||||
--hidden-import=tkinter ^
|
|
||||||
--hidden-import=tkinter.ttk ^
|
|
||||||
--hidden-import=tkinter.scrolledtext ^
|
|
||||||
--hidden-import=tkinter.messagebox ^
|
|
||||||
--hidden-import=yaml ^
|
|
||||||
--hidden-import=requests ^
|
|
||||||
--collect-all pyqt6 ^
|
|
||||||
src/main.py
|
|
||||||
|
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo.
|
echo.
|
||||||
echo ================================
|
echo ===================================
|
||||||
echo Build Failed!
|
echo 构建失败!
|
||||||
echo ================================
|
echo ===================================
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ================================
|
echo ===================================
|
||||||
echo Build Complete!
|
echo 构建成功!
|
||||||
echo Executable: dist\CutThenThink.exe
|
echo ===================================
|
||||||
echo File size: ~30-50 MB
|
|
||||||
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.
|
echo.
|
||||||
pause
|
pause
|
||||||
|
|||||||
87
build.sh
87
build.sh
@@ -1,71 +1,46 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# CutThenThink 简化打包脚本
|
# CutThenThink 极简版本打包脚本
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "==================================="
|
echo "==================================="
|
||||||
echo "CutThenThink 打包脚本"
|
echo "CutThenThink v2.0 极简版构建"
|
||||||
|
echo "==================================="
|
||||||
|
echo ""
|
||||||
|
echo "特点:"
|
||||||
|
echo "- 核心依赖:PyQt6, requests, Pillow"
|
||||||
|
echo "- 可选 OCR:RapidOCR 插件"
|
||||||
|
echo "- 无重型依赖:torch, transformers, paddleocr"
|
||||||
echo "==================================="
|
echo "==================================="
|
||||||
|
|
||||||
# 使用系统Python和pip
|
# 检测 Python
|
||||||
PYTHON="python3"
|
PYTHON="python3"
|
||||||
PIP="python3 -m pip"
|
if ! command -v python3 &>/dev/null; then
|
||||||
|
PYTHON="python"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
PIP="$PYTHON -m pip"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "4/4. 开始打包..."
|
echo "[1/5] 清理旧的构建..."
|
||||||
$PYTHON -m PyInstaller \
|
rm -rf build dist
|
||||||
--name "CutThenThink" \
|
|
||||||
--windowed \
|
echo ""
|
||||||
--onefile \
|
echo "[2/5] 安装构建依赖..."
|
||||||
--add-data "src:src" \
|
$PIP install --user pyinstaller 2>/dev/null || echo " PyInstaller 已安装"
|
||||||
--hidden-import=PyQt6.QtCore \
|
|
||||||
--hidden-import=PyQt6.QtGui \
|
echo ""
|
||||||
--hidden-import=PyQt6.QtWidgets \
|
echo "[3/5] 构建可执行文件..."
|
||||||
--hidden-import=sqlalchemy \
|
$PYTHON -m PyInstaller CutThenThink.spec --clean
|
||||||
src/main.py
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "==================================="
|
echo "==================================="
|
||||||
echo "打包完成!"
|
echo "构建完成!"
|
||||||
echo "可执行文件: dist/CutThenThink"
|
|
||||||
echo "==================================="
|
|
||||||
|
|
||||||
# 测试运行提示
|
|
||||||
echo ""
|
echo ""
|
||||||
read -p "是否测试运行?(y/n) " -n 1 -r
|
echo "输出位置:"
|
||||||
echo
|
echo " - dist/CutThenThink/ # 可执行文件"
|
||||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
echo ""
|
||||||
echo "启动测试..."
|
echo "首次运行前请配置:"
|
||||||
./dist/CutThenThink
|
echo " 1. 可选安装 OCR:pip install -r requirements-ocr.txt"
|
||||||
fi
|
echo " 2. 配置文件:~/.cutthenthink/config.yaml"
|
||||||
|
echo "==================================="
|
||||||
|
|||||||
162
docs/BUILD.md
162
docs/BUILD.md
@@ -1,74 +1,136 @@
|
|||||||
# CutThenThink 打包指南
|
# CutThenThink 打包指南
|
||||||
|
|
||||||
## 方式一:使用 build.sh 脚本(推荐)
|
## Windows 打包
|
||||||
|
|
||||||
在您的本地环境(有管理员权限)中运行:
|
### 方法一:使用批处理脚本(推荐)
|
||||||
|
|
||||||
```bash
|
```cmd
|
||||||
cd /path/to/CutThenThink
|
# 双击运行
|
||||||
bash build.sh
|
build.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 方法二:手动命令
|
||||||
|
|
||||||
## 方式二:手动打包
|
```cmd
|
||||||
|
# 1. 安装依赖
|
||||||
### 1. 安装 PyInstaller
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 pipx(推荐)
|
|
||||||
pipx install pyinstaller
|
|
||||||
|
|
||||||
# 或使用系统包管理器
|
|
||||||
sudo apt install python3-pyinstaller
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 安装项目依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. 安装 PyInstaller
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
# 3. 构建
|
||||||
|
python -m PyInstaller CutThenThink.spec --clean
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 执行打包
|
### 输出位置
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
└── CutThenThink/
|
||||||
|
├── CutThenThink.exe # 主程序
|
||||||
|
└── _internal/ # 运行时依赖
|
||||||
|
```
|
||||||
|
|
||||||
|
### 可选:安装 OCR 支持
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
pip install -r requirements-ocr.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linux/macOS 打包
|
||||||
|
|
||||||
|
### 使用 Shell 脚本
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m PyInstaller \
|
# 添加执行权限
|
||||||
--name "CutThenThink" \
|
chmod +x build.sh
|
||||||
--windowed \
|
|
||||||
--onefile \
|
# 运行
|
||||||
--add-data "src:src" \
|
./build.sh
|
||||||
--hidden-import=PyQt6.QtCore \
|
|
||||||
--hidden-import=PyQt6.QtGui \
|
|
||||||
--hidden-import=PyQt6.QtWidgets \
|
|
||||||
--hidden-import=sqlalchemy \
|
|
||||||
src/main.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 测试运行
|
### 手动命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./dist/CutThenThink
|
# 1. 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. 安装 PyInstaller
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
# 3. 构建
|
||||||
|
python -m PyInstaller CutThenThink.spec --clean
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 打包说明
|
||||||
|
|
||||||
## 打包参数说明
|
### PyInstaller 配置
|
||||||
|
|
||||||
| 参数 | 说明 |
|
- `--onefile`: 打包成单个 EXE
|
||||||
|------|------|
|
- `--windowed`: 无控制台窗口
|
||||||
| `--name` | 应用名称 |
|
- `--upx`: 使用 UPX 压缩(减小体积)
|
||||||
| `--windowed` | 无控制台窗口 |
|
- `--clean`: 清理旧的构建
|
||||||
| `--onefile` | 单文件打包 |
|
|
||||||
| `--add-data` | 添加数据文件(源代码) |
|
|
||||||
| `--hidden-import` | 隐式导入模块 |
|
|
||||||
|
|
||||||
---
|
### 包含的隐式导入
|
||||||
|
|
||||||
## 打包后
|
- PyQt6.QtCore
|
||||||
|
- PyQt6.QtGui
|
||||||
|
- PyQt6.QtWidgets
|
||||||
|
- yaml
|
||||||
|
|
||||||
可执行文件位置:`dist/CutThenThink`
|
### 排除的模块
|
||||||
|
|
||||||
分发时建议:
|
- test, tests, pytest
|
||||||
1. 将 `dist/CutThenThink` 打包为 tar.gz
|
|
||||||
2. 创建安装脚本
|
## 首次运行配置
|
||||||
3. 包含 README 说明
|
|
||||||
|
程序首次运行时会创建配置文件:
|
||||||
|
|
||||||
|
**Windows**: `%USERPROFILE%\.cutthenthink\config.yaml`
|
||||||
|
**Linux/macOS**: `~/.cutthenthink/config.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
upload:
|
||||||
|
provider: custom
|
||||||
|
endpoint: https://your-server.com/upload
|
||||||
|
api_key: your-api-key
|
||||||
|
auto_copy: true
|
||||||
|
|
||||||
|
screenshot:
|
||||||
|
format: png
|
||||||
|
save_path: ~/Pictures/Screenshots
|
||||||
|
|
||||||
|
hotkeys:
|
||||||
|
capture: Ctrl+Shift+A
|
||||||
|
region: Ctrl+Shift+R
|
||||||
|
upload: Ctrl+Shift+U
|
||||||
|
|
||||||
|
ocr:
|
||||||
|
enabled: false
|
||||||
|
auto_copy: false
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### PyInstaller 构建失败
|
||||||
|
|
||||||
|
1. 确保 PyInstaller 已安装:`pip list | grep pyinstaller`
|
||||||
|
2. 检查 Python 版本:需要 Python 3.8+
|
||||||
|
3. 清理缓存:删除 `build/` 和 `dist/` 目录后重试
|
||||||
|
|
||||||
|
### 运行时错误
|
||||||
|
|
||||||
|
1. 检查是否有防火墙阻止
|
||||||
|
2. 检查 OCR 功能是否可选安装
|
||||||
|
3. 查看日志文件(如果有)
|
||||||
|
|
||||||
|
## 体积优化建议
|
||||||
|
|
||||||
|
当前打包体积预估:
|
||||||
|
- 核心依赖:~50MB
|
||||||
|
- 可选 OCR:+10MB
|
||||||
|
|
||||||
|
如需进一步减小体积:
|
||||||
|
1. 使用 `--exclude-module` 排除不需要的模块
|
||||||
|
2. 启用 UPX 压缩(已启用)
|
||||||
|
3. 使用虚拟环境减少依赖
|
||||||
|
|||||||
302
docs/需求分析报告.md
Normal file
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 框架
|
# GUI 框架
|
||||||
PyQt6==6.6.1
|
PyQt6>=6.7.0
|
||||||
PyQt6-WebEngine==6.6.0
|
|
||||||
|
|
||||||
# 数据库
|
# 图片处理
|
||||||
SQLAlchemy==2.0.25
|
Pillow>=10.0.0
|
||||||
|
|
||||||
# OCR识别
|
# HTTP 请求
|
||||||
paddleocr>=2.7.0
|
|
||||||
paddlepaddle>=2.6.0
|
|
||||||
|
|
||||||
# AI服务
|
|
||||||
openai>=1.0.0
|
|
||||||
anthropic>=0.18.0
|
|
||||||
|
|
||||||
# 工具库
|
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
pyyaml>=6.0.1
|
|
||||||
pillow>=10.0.0
|
# 剪贴板
|
||||||
pyperclip>=1.8.2
|
pyperclip>=1.8.2
|
||||||
|
|
||||||
|
# 配置文件
|
||||||
|
pyyaml>=6.0.1
|
||||||
|
|||||||
170
src/config.py
Normal file
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 (
|
from src.core.ocr import (
|
||||||
# 基础类
|
# 基础类
|
||||||
BaseOCREngine,
|
BaseOCREngine,
|
||||||
PaddleOCREngine,
|
|
||||||
CloudOCREngine,
|
CloudOCREngine,
|
||||||
OCRFactory,
|
OCRFactory,
|
||||||
|
OCRProvider,
|
||||||
|
|
||||||
# 结果模型
|
# 结果模型
|
||||||
OCRResult,
|
OCRResult,
|
||||||
@@ -68,9 +68,9 @@ from src.core.processor import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
# OCR 模块
|
# OCR 模块
|
||||||
'BaseOCREngine',
|
'BaseOCREngine',
|
||||||
'PaddleOCREngine',
|
|
||||||
'CloudOCREngine',
|
'CloudOCREngine',
|
||||||
'OCRFactory',
|
'OCRFactory',
|
||||||
|
'OCRProvider',
|
||||||
'OCRResult',
|
'OCRResult',
|
||||||
'OCRBatchResult',
|
'OCRBatchResult',
|
||||||
'ImagePreprocessor',
|
'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,835 +0,0 @@
|
|||||||
"""
|
|
||||||
错误提示和日志系统的 GUI 集成
|
|
||||||
|
|
||||||
提供统一的消息处理和错误显示功能
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, Callable, List, Dict, Any
|
|
||||||
|
|
||||||
# 尝试导入 tkinter,失败时使用 PyQt6
|
|
||||||
try:
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, messagebox, filedialog
|
|
||||||
HAS_TKINTER = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_TKINTER = False
|
|
||||||
# 使用 PyQt6 作为替代
|
|
||||||
from PyQt6.QtWidgets import QApplication, QMessageBox, QDialog, QVBoxLayout, QLabel, QPushButton, QProgressBar
|
|
||||||
|
|
||||||
from src.utils.logger import get_logger, LogCapture
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class LogLevel:
|
|
||||||
"""日志级别"""
|
|
||||||
DEBUG = "DEBUG"
|
|
||||||
INFO = "INFO"
|
|
||||||
WARNING = "WARNING"
|
|
||||||
ERROR = "ERROR"
|
|
||||||
CRITICAL = "CRITICAL"
|
|
||||||
|
|
||||||
|
|
||||||
# PyQt6 替代实现(当 tkinter 不可用时)
|
|
||||||
class QtMessageHandler:
|
|
||||||
"""使用 PyQt6 的消息处理器"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def show_info(title: str, message: str, parent=None):
|
|
||||||
QMessageBox.information(parent, title, message)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def show_warning(title: str, message: str, parent=None):
|
|
||||||
QMessageBox.warning(parent, title, message)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def show_error(title: str, message: str, parent=None):
|
|
||||||
QMessageBox.critical(parent, title, message)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def ask_yes_no(title: str, message: str, default: bool = True, parent=None) -> bool:
|
|
||||||
reply = QMessageBox.question(
|
|
||||||
parent, title, message,
|
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
||||||
QMessageBox.StandardButton.Yes if default else QMessageBox.StandardButton.No
|
|
||||||
)
|
|
||||||
return reply == QMessageBox.StandardButton.Yes
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def ask_ok_cancel(title: str, message: str, default: bool = True, parent=None) -> bool:
|
|
||||||
reply = QMessageBox.question(
|
|
||||||
parent, title, message,
|
|
||||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
|
|
||||||
QMessageBox.StandardButton.Ok if default else QMessageBox.StandardButton.Cancel
|
|
||||||
)
|
|
||||||
return reply == QMessageBox.StandardButton.Ok
|
|
||||||
|
|
||||||
|
|
||||||
class MessageHandler:
|
|
||||||
"""
|
|
||||||
消息处理器
|
|
||||||
|
|
||||||
负责显示各种类型的消息和错误
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
"""
|
|
||||||
初始化消息处理器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: 父窗口
|
|
||||||
"""
|
|
||||||
self.parent = parent
|
|
||||||
self.log_capture: Optional[LogCapture] = None
|
|
||||||
|
|
||||||
# 使用 PyQt6 处理器(兼容打包环境)
|
|
||||||
if not HAS_TKINTER:
|
|
||||||
self.qt_handler = QtMessageHandler()
|
|
||||||
else:
|
|
||||||
self.qt_handler = None
|
|
||||||
|
|
||||||
def set_log_capture(self, capture: LogCapture):
|
|
||||||
"""
|
|
||||||
设置日志捕获器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
capture: 日志捕获器
|
|
||||||
"""
|
|
||||||
self.log_capture = capture
|
|
||||||
|
|
||||||
def show_info(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
message: str,
|
|
||||||
details: Optional[str] = None,
|
|
||||||
log: bool = True
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
显示信息对话框
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: 标题
|
|
||||||
message: 消息内容
|
|
||||||
details: 详细信息(可选)
|
|
||||||
log: 是否记录到日志
|
|
||||||
"""
|
|
||||||
if log:
|
|
||||||
logger.info(message)
|
|
||||||
|
|
||||||
if details:
|
|
||||||
full_message = f"{message}\n\n详细信息:\n{details}"
|
|
||||||
else:
|
|
||||||
full_message = message
|
|
||||||
|
|
||||||
if not HAS_TKINTER:
|
|
||||||
self.qt_handler.show_info(title, full_message, self.parent)
|
|
||||||
else:
|
|
||||||
if self.parent:
|
|
||||||
messagebox.showinfo(title, full_message, parent=self.parent)
|
|
||||||
else:
|
|
||||||
messagebox.showinfo(title, full_message)
|
|
||||||
|
|
||||||
def show_warning(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
message: str,
|
|
||||||
details: Optional[str] = None,
|
|
||||||
log: bool = True
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
显示警告对话框
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: 标题
|
|
||||||
message: 消息内容
|
|
||||||
details: 详细信息(可选)
|
|
||||||
log: 是否记录到日志
|
|
||||||
"""
|
|
||||||
if log:
|
|
||||||
logger.warning(message)
|
|
||||||
|
|
||||||
if details:
|
|
||||||
full_message = f"{message}\n\n详细信息:\n{details}"
|
|
||||||
else:
|
|
||||||
full_message = message
|
|
||||||
|
|
||||||
if not HAS_TKINTER:
|
|
||||||
self.qt_handler.show_warning(title, full_message, self.parent)
|
|
||||||
else:
|
|
||||||
if self.parent:
|
|
||||||
messagebox.showwarning(title, full_message, parent=self.parent)
|
|
||||||
else:
|
|
||||||
messagebox.showwarning(title, full_message)
|
|
||||||
|
|
||||||
def show_error(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
message: str,
|
|
||||||
details: Optional[str] = None,
|
|
||||||
exception: Optional[Exception] = None,
|
|
||||||
log: bool = True
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
显示错误对话框
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: 标题
|
|
||||||
message: 消息内容
|
|
||||||
details: 详细信息(可选)
|
|
||||||
exception: 异常对象(可选)
|
|
||||||
log: 是否记录到日志
|
|
||||||
"""
|
|
||||||
if log:
|
|
||||||
logger.error(message, exc_info=exception is not None)
|
|
||||||
|
|
||||||
# 构建完整消息
|
|
||||||
full_message = message
|
|
||||||
|
|
||||||
if exception:
|
|
||||||
full_message += f"\n\n错误类型: {type(exception).__name__}"
|
|
||||||
|
|
||||||
if details:
|
|
||||||
full_message += f"\n\n详细信息:\n{details}"
|
|
||||||
|
|
||||||
if not HAS_TKINTER:
|
|
||||||
self.qt_handler.show_error(title, full_message, self.parent)
|
|
||||||
else:
|
|
||||||
if self.parent:
|
|
||||||
messagebox.showerror(title, full_message, parent=self.parent)
|
|
||||||
else:
|
|
||||||
messagebox.showerror(title, full_message)
|
|
||||||
|
|
||||||
def ask_yes_no(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
message: str,
|
|
||||||
default: bool = True
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
询问是/否
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: 标题
|
|
||||||
message: 消息内容
|
|
||||||
default: 默认值(True=是,False=否)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
用户选择(True=是,False=否)
|
|
||||||
"""
|
|
||||||
if not HAS_TKINTER:
|
|
||||||
result = self.qt_handler.ask_yes_no(title, message, default, self.parent)
|
|
||||||
else:
|
|
||||||
if self.parent:
|
|
||||||
result = messagebox.askyesno(title, message, parent=self.parent, default=default)
|
|
||||||
else:
|
|
||||||
result = messagebox.askyesno(title, message, default=default)
|
|
||||||
logger.info(f"用户选择: {'是' if result else '否'} ({message})")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def ask_ok_cancel(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
message: str,
|
|
||||||
default: bool = True
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
询问确定/取消
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: 标题
|
|
||||||
message: 消息内容
|
|
||||||
default: 默认值(True=确定,False=取消)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
用户选择(True=确定,False=取消)
|
|
||||||
"""
|
|
||||||
if not HAS_TKINTER:
|
|
||||||
result = self.qt_handler.ask_ok_cancel(title, message, default, self.parent)
|
|
||||||
else:
|
|
||||||
if self.parent:
|
|
||||||
result = messagebox.askokcancel(title, message, parent=self.parent, default=default)
|
|
||||||
else:
|
|
||||||
result = messagebox.askokcancel(title, message, default=default)
|
|
||||||
logger.info(f"用户选择: {'确定' if result else '取消'} ({message})")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def ask_retry_cancel(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
message: str,
|
|
||||||
default: str = "retry"
|
|
||||||
) -> Optional[bool]:
|
|
||||||
"""
|
|
||||||
询问重试/取消
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: 标题
|
|
||||||
message: 消息内容
|
|
||||||
default: 默认选项 ("retry" 或 "cancel")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
用户选择(True=重试,False=取消,None=关闭)
|
|
||||||
"""
|
|
||||||
if not HAS_TKINTER:
|
|
||||||
# PyQt6 版本使用简化的实现
|
|
||||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton
|
|
||||||
|
|
||||||
dialog = QDialog(self.parent)
|
|
||||||
dialog.setWindowTitle(title)
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
label = QLabel(message)
|
|
||||||
layout.addWidget(label)
|
|
||||||
|
|
||||||
btn_layout = QHBoxLayout()
|
|
||||||
retry_btn = QPushButton("重试")
|
|
||||||
cancel_btn = QPushButton("取消")
|
|
||||||
|
|
||||||
if default == "retry":
|
|
||||||
retry_btn.setDefault(True)
|
|
||||||
else:
|
|
||||||
cancel_btn.setDefault(True)
|
|
||||||
|
|
||||||
btn_layout.addWidget(retry_btn)
|
|
||||||
btn_layout.addWidget(cancel_btn)
|
|
||||||
layout.addLayout(btn_layout)
|
|
||||||
|
|
||||||
dialog.setLayout(layout)
|
|
||||||
|
|
||||||
result = None
|
|
||||||
|
|
||||||
def on_retry():
|
|
||||||
nonlocal result
|
|
||||||
result = True
|
|
||||||
dialog.accept()
|
|
||||||
|
|
||||||
def on_cancel():
|
|
||||||
nonlocal result
|
|
||||||
result = False
|
|
||||||
dialog.accept()
|
|
||||||
|
|
||||||
retry_btn.clicked.connect(on_retry)
|
|
||||||
cancel_btn.clicked.connect(on_cancel)
|
|
||||||
|
|
||||||
dialog.exec()
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
if self.parent:
|
|
||||||
result = messagebox.askretrycancel(title, message, parent=self.parent, default=default == "retry")
|
|
||||||
else:
|
|
||||||
result = messagebox.askretrycancel(title, message, default=default == "retry")
|
|
||||||
|
|
||||||
if result is True:
|
|
||||||
logger.info(f"用户选择: 重试 ({message})")
|
|
||||||
elif result is False:
|
|
||||||
logger.info(f"用户选择: 取消 ({message})")
|
|
||||||
else:
|
|
||||||
logger.info(f"用户选择: 关闭 ({message})")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# PyQt6 进度对话框(替代 tkinter 版本)
|
|
||||||
class QtProgressDialog(QDialog):
|
|
||||||
"""PyQt6 进度对话框"""
|
|
||||||
|
|
||||||
def __init__(self, parent, title: str = "处理中", message: str = "请稍候...", cancelable: bool = False):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle(title)
|
|
||||||
self.setFixedSize(400, 150)
|
|
||||||
self.setModal(True)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
self.message_label = QLabel(message)
|
|
||||||
layout.addWidget(self.message_label)
|
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QProgressBar
|
|
||||||
self.progress_bar = QProgressBar()
|
|
||||||
self.progress_bar.setRange(0, 0) # indeterminate mode
|
|
||||||
self.progress_bar.setTextVisible(False)
|
|
||||||
layout.addWidget(self.progress_bar)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
def set_message(self, message: str):
|
|
||||||
self.message_label.setText(message)
|
|
||||||
|
|
||||||
def set_detail(self, detail: str):
|
|
||||||
self.message_label.setText(f"{self.message_label.text()}\n{detail}")
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
self.accept()
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorLogViewer:
|
|
||||||
"""
|
|
||||||
错误日志查看器(PyQt6 版本)
|
|
||||||
|
|
||||||
显示详细的错误和日志信息
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent,
|
|
||||||
title: str = "错误日志",
|
|
||||||
errors: Optional[List[Dict[str, Any]]] = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
初始化错误日志查看器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: 父窗口
|
|
||||||
title: 窗口标题
|
|
||||||
errors: 错误列表
|
|
||||||
"""
|
|
||||||
self.parent = parent
|
|
||||||
self.errors = errors or []
|
|
||||||
|
|
||||||
if HAS_TKINTER:
|
|
||||||
# 使用 tkinter 实现
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk
|
|
||||||
super(tk.Toplevel, self).__init__(parent)
|
|
||||||
self.title(title)
|
|
||||||
self.geometry("800x600")
|
|
||||||
self._create_tk_ui()
|
|
||||||
else:
|
|
||||||
# 使用 PyQt6 实现
|
|
||||||
from PyQt6.QtWidgets import (
|
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|
||||||
QComboBox, QTextEdit, QScrollBar, QPushButton
|
|
||||||
)
|
|
||||||
from PyQt6.QtCore import Qt
|
|
||||||
|
|
||||||
super(QDialog, self).__init__(parent)
|
|
||||||
self.setWindowTitle(title)
|
|
||||||
self.resize(800, 600)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# 工具栏
|
|
||||||
toolbar_layout = QHBoxLayout()
|
|
||||||
toolbar_layout.addWidget(QLabel("日志级别:"))
|
|
||||||
|
|
||||||
self.level_combo = QComboBox()
|
|
||||||
self.level_combo.addItems(["ALL", "ERROR", "WARNING", "INFO", "DEBUG"])
|
|
||||||
self.level_combo.setCurrentText("ERROR")
|
|
||||||
toolbar_layout.addWidget(self.level_combo)
|
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QPushButton
|
|
||||||
clear_btn = QPushButton("清空")
|
|
||||||
export_btn = QPushButton("导出")
|
|
||||||
close_btn = QPushButton("关闭")
|
|
||||||
|
|
||||||
toolbar_layout.addWidget(clear_btn)
|
|
||||||
toolbar_layout.addWidget(export_btn)
|
|
||||||
toolbar_layout.addWidget(close_btn)
|
|
||||||
|
|
||||||
layout.addLayout(toolbar_layout)
|
|
||||||
|
|
||||||
# 文本区域
|
|
||||||
self.text_widget = QTextEdit()
|
|
||||||
self.text_widget.setReadOnly(True)
|
|
||||||
self.text_widget.setFont(QtGui.QFont("Consolas", 9))
|
|
||||||
layout.addWidget(self.text_widget)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
# 连接信号
|
|
||||||
clear_btn.clicked.connect(self._on_clear)
|
|
||||||
export_btn.clicked.connect(self._on_export)
|
|
||||||
close_btn.clicked.connect(self.accept)
|
|
||||||
self.level_combo.currentTextChanged.connect(self._load_errors)
|
|
||||||
|
|
||||||
self._load_errors()
|
|
||||||
|
|
||||||
def _create_tk_ui(self):
|
|
||||||
"""创建 tkinter UI"""
|
|
||||||
from tkinter import ttk
|
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
# 工具栏
|
|
||||||
toolbar = ttk.Frame(self)
|
|
||||||
toolbar.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
|
|
||||||
|
|
||||||
ttk.Label(toolbar, text="日志级别:").pack(side=tk.LEFT, padx=5)
|
|
||||||
self.level_var = tk.StringVar(value="ERROR")
|
|
||||||
level_combo = ttk.Combobox(
|
|
||||||
toolbar,
|
|
||||||
textvariable=self.level_var,
|
|
||||||
values=["ALL", "ERROR", "WARNING", "INFO", "DEBUG"],
|
|
||||||
width=10,
|
|
||||||
state=tk.READONLY
|
|
||||||
)
|
|
||||||
level_combo.pack(side=tk.LEFT, padx=5)
|
|
||||||
level_combo.bind("<<ComboboxSelected>>", self._on_filter_change)
|
|
||||||
|
|
||||||
ttk.Button(toolbar, text="清空", command=self._on_clear).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Button(toolbar, text="导出", command=self._on_export).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Button(toolbar, text="关闭", command=self.destroy).pack(side=tk.RIGHT, padx=5)
|
|
||||||
|
|
||||||
# 主内容区域
|
|
||||||
content_frame = ttk.Frame(self)
|
|
||||||
content_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5)
|
|
||||||
|
|
||||||
# 创建文本控件
|
|
||||||
self.text_widget = tk.Text(
|
|
||||||
content_frame,
|
|
||||||
wrap=tk.WORD,
|
|
||||||
font=("Consolas", 9)
|
|
||||||
)
|
|
||||||
self.text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 滚动条
|
|
||||||
scrollbar = ttk.Scrollbar(content_frame, orient=tk.VERTICAL, command=self.text_widget.yview)
|
|
||||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
self.text_widget.config(yscrollcommand=scrollbar.set)
|
|
||||||
|
|
||||||
def _on_filter_change(self, event=None):
|
|
||||||
"""过滤器改变"""
|
|
||||||
self._load_errors()
|
|
||||||
|
|
||||||
def _on_clear(self):
|
|
||||||
"""清空日志"""
|
|
||||||
self.errors.clear()
|
|
||||||
if HAS_TKINTER and hasattr(self.text_widget, 'delete'):
|
|
||||||
self.text_widget.delete("1.0", tk.END)
|
|
||||||
else:
|
|
||||||
self.text_widget.clear()
|
|
||||||
self.status_label.config(text="已清空")
|
|
||||||
|
|
||||||
def _on_export(self):
|
|
||||||
"""导出日志"""
|
|
||||||
if HAS_TKINTER:
|
|
||||||
from tkinter import filedialog
|
|
||||||
filename = filedialog.asksaveasfilename(
|
|
||||||
parent=self,
|
|
||||||
title="导出日志",
|
|
||||||
defaultextension=".txt",
|
|
||||||
filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
from PyQt6.QtWidgets import QFileDialog
|
|
||||||
filename, _ = QFileDialog.getSaveFileName(
|
|
||||||
self,
|
|
||||||
"导出日志",
|
|
||||||
"",
|
|
||||||
"文本文件 (*.txt);;所有文件 (*.*)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if filename:
|
|
||||||
try:
|
|
||||||
content = self.text_widget.toPlainText() if not HAS_TKINTER else self.text_widget.get("1.0", tk.END)
|
|
||||||
with open(filename, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(content)
|
|
||||||
if HAS_TKINTER:
|
|
||||||
from tkinter import messagebox
|
|
||||||
messagebox.showinfo("导出成功", f"日志已导出到:\n{filename}")
|
|
||||||
else:
|
|
||||||
from PyQt6.QtWidgets import QMessageBox
|
|
||||||
QMessageBox.information(self, "导出成功", f"日志已导出到:\n{filename}")
|
|
||||||
except Exception as e:
|
|
||||||
if HAS_TKINTER:
|
|
||||||
from tkinter import messagebox
|
|
||||||
messagebox.showerror("导出失败", f"导出失败:\n{e}")
|
|
||||||
else:
|
|
||||||
from PyQt6.QtWidgets import QMessageBox
|
|
||||||
QMessageBox.critical(self, "导出失败", f"导出失败:\n{e}")
|
|
||||||
|
|
||||||
def add_error(self, level: str, message: str, timestamp: Optional[datetime] = None):
|
|
||||||
"""
|
|
||||||
添加错误
|
|
||||||
|
|
||||||
Args:
|
|
||||||
level: 日志级别
|
|
||||||
message: 消息
|
|
||||||
timestamp: 时间戳
|
|
||||||
"""
|
|
||||||
if timestamp is None:
|
|
||||||
timestamp = datetime.now()
|
|
||||||
|
|
||||||
self.errors.append({
|
|
||||||
"level": level,
|
|
||||||
"message": message,
|
|
||||||
"timestamp": timestamp
|
|
||||||
})
|
|
||||||
|
|
||||||
self._load_errors()
|
|
||||||
|
|
||||||
def _load_errors(self):
|
|
||||||
"""加载错误"""
|
|
||||||
level_filter = self.level_combo.currentText() if not HAS_TKINTER else self.level_var.get()
|
|
||||||
|
|
||||||
if not HAS_TKINTER:
|
|
||||||
self.text_widget.clear()
|
|
||||||
import tkinter as tk
|
|
||||||
else:
|
|
||||||
self.text_widget.delete("1.0", tk.END)
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for error in self.errors:
|
|
||||||
level = error.get("level", "INFO")
|
|
||||||
|
|
||||||
# 过滤
|
|
||||||
if level_filter != "ALL" and level != level_filter:
|
|
||||||
continue
|
|
||||||
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
timestamp = error.get("timestamp", datetime.now())
|
|
||||||
message = error.get("message", "")
|
|
||||||
|
|
||||||
# 格式化时间
|
|
||||||
if isinstance(timestamp, datetime):
|
|
||||||
time_str = timestamp.strftime("%H:%M:%S")
|
|
||||||
else:
|
|
||||||
time_str = str(timestamp)
|
|
||||||
|
|
||||||
# 插入内容
|
|
||||||
if HAS_TKINTER:
|
|
||||||
import tkinter as tk
|
|
||||||
self.text_widget.insert(tk.END, f"[{time_str}] ", "timestamp")
|
|
||||||
self.text_widget.insert(tk.END, f"[{level}] ", level)
|
|
||||||
self.text_widget.insert(tk.END, f"{message}\n")
|
|
||||||
else:
|
|
||||||
self.text_widget.append(f"[{time_str}] [{level}] {message}")
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressDialog:
|
|
||||||
"""
|
|
||||||
进度对话框(选择 Tkinter 或 PyQt6 实现)
|
|
||||||
|
|
||||||
显示处理进度和状态
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent,
|
|
||||||
title: str = "处理中",
|
|
||||||
message: str = "请稍候...",
|
|
||||||
cancelable: bool = False,
|
|
||||||
on_cancel: Optional[Callable] = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
初始化进度对话框
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: 父窗口
|
|
||||||
title: 标题
|
|
||||||
message: 消息
|
|
||||||
cancelable: 是否可取消
|
|
||||||
on_cancel: 取消回调
|
|
||||||
"""
|
|
||||||
if HAS_TKINTER:
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk
|
|
||||||
super(tk.Toplevel, self).__init__(parent)
|
|
||||||
self.title(title)
|
|
||||||
self.geometry("400x150")
|
|
||||||
self.resizable(False, False)
|
|
||||||
self.transient(parent)
|
|
||||||
self.grab_set()
|
|
||||||
self._impl = _TkProgressDialog(self, on_cancel)
|
|
||||||
self._impl._create_ui(message, cancelable)
|
|
||||||
else:
|
|
||||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton
|
|
||||||
super(QDialog, self).__init__(parent)
|
|
||||||
self.setWindowTitle(title)
|
|
||||||
self.setFixedSize(400, 150)
|
|
||||||
self.setModal(True)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
self.message_label = QLabel(message)
|
|
||||||
layout.addWidget(self.message_label)
|
|
||||||
|
|
||||||
self.progress_bar = QProgressBar()
|
|
||||||
self.progress_bar.setRange(0, 0) # indeterminate
|
|
||||||
self.progress_bar.setTextVisible(False)
|
|
||||||
layout.addWidget(self.progress_bar)
|
|
||||||
|
|
||||||
if cancelable:
|
|
||||||
from PyQt6.QtCore import Qt
|
|
||||||
cancel_btn = QPushButton("取消")
|
|
||||||
cancel_btn.clicked.connect(self._on_cancel)
|
|
||||||
layout.addWidget(cancel_btn)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
# 居中显示
|
|
||||||
if parent:
|
|
||||||
x = parent.x() + (parent.width() - self.width()) // 2
|
|
||||||
y = parent.y() + (parent.height() - self.height()) // 2
|
|
||||||
self.move(x, y)
|
|
||||||
|
|
||||||
self._impl = self
|
|
||||||
self.progress_bar = None # 标记不存在
|
|
||||||
self.on_cancel_callback = on_cancel
|
|
||||||
self.cancelled = False
|
|
||||||
|
|
||||||
# 启动进度条动画
|
|
||||||
if HAS_TKINTER:
|
|
||||||
self._impl.progress_bar.start(10)
|
|
||||||
else:
|
|
||||||
from PyQt6.QtCore import QPropertyAnimation
|
|
||||||
# PyQt6 不需要手动启动动画
|
|
||||||
|
|
||||||
def set_message(self, message: str):
|
|
||||||
"""
|
|
||||||
设置消息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: 消息内容
|
|
||||||
"""
|
|
||||||
if HAS_TKINTER:
|
|
||||||
self._impl.set_message(message)
|
|
||||||
else:
|
|
||||||
self.message_label.setText(message)
|
|
||||||
|
|
||||||
def set_detail(self, detail: str):
|
|
||||||
"""
|
|
||||||
设置详细信息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
detail: 详细信息
|
|
||||||
"""
|
|
||||||
if HAS_TKINTER:
|
|
||||||
self._impl.set_detail(detail)
|
|
||||||
else:
|
|
||||||
self.message_label.setText(f"{self.message_label.text()}\n{detail}")
|
|
||||||
|
|
||||||
def set_progress(self, value: float, maximum: float = 100):
|
|
||||||
"""
|
|
||||||
设置进度值
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: 当前进度值
|
|
||||||
maximum: 最大值
|
|
||||||
"""
|
|
||||||
if HAS_TKINTER:
|
|
||||||
self._impl.set_progress(value, maximum)
|
|
||||||
else:
|
|
||||||
if self.progress_bar:
|
|
||||||
self.progress_bar.setRange(0, int(maximum))
|
|
||||||
self.progress_bar.setValue(int(value))
|
|
||||||
else:
|
|
||||||
from PyQt6.QtWidgets import QProgressBar
|
|
||||||
self.progress_bar = QProgressBar()
|
|
||||||
self.progress_bar.setRange(0, int(maximum))
|
|
||||||
self.progress_bar.setValue(int(value))
|
|
||||||
|
|
||||||
def _on_cancel(self):
|
|
||||||
"""取消按钮点击"""
|
|
||||||
self.cancelled = True
|
|
||||||
if self.on_cancel_callback:
|
|
||||||
self.on_cancel_callback()
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def is_cancelled(self) -> bool:
|
|
||||||
"""
|
|
||||||
检查是否已取消
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否已取消
|
|
||||||
"""
|
|
||||||
return self.cancelled
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
"""关闭对话框"""
|
|
||||||
self.accept()
|
|
||||||
|
|
||||||
|
|
||||||
class _TkProgressDialog:
|
|
||||||
"""Tkinter 进度对话框实现"""
|
|
||||||
|
|
||||||
def __init__(self, on_cancel):
|
|
||||||
self.on_cancel_callback = on_cancel
|
|
||||||
self.cancelled = False
|
|
||||||
self.progress_bar = None
|
|
||||||
|
|
||||||
def _create_ui(self, message: str, cancelable: bool):
|
|
||||||
"""创建 UI"""
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk
|
|
||||||
|
|
||||||
# 主容器
|
|
||||||
main_frame = ttk.Frame(self, padding=20)
|
|
||||||
main_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 消息标签
|
|
||||||
self.message_label = ttk.Label(main_frame, text=message, font=("Arial", 10))
|
|
||||||
self.message_label.pack(side=tk.TOP, pady=(0, 20))
|
|
||||||
|
|
||||||
# 进度条
|
|
||||||
self.progress_bar = ttk.Progressbar(
|
|
||||||
main_frame,
|
|
||||||
mode='indeterminate',
|
|
||||||
length=350
|
|
||||||
)
|
|
||||||
self.progress_bar.pack(side=tk.TOP, pady=(0, 10))
|
|
||||||
|
|
||||||
# 启动进度条动画
|
|
||||||
self.progress_bar.start(10)
|
|
||||||
|
|
||||||
# 详细信息标签
|
|
||||||
self.detail_label = ttk.Label(main_frame, text="", font=("Arial", 9))
|
|
||||||
self.detail_label.pack(side=tk.TOP, pady=(0, 20))
|
|
||||||
|
|
||||||
# 取消按钮
|
|
||||||
if cancelable:
|
|
||||||
cancel_btn = ttk.Button(main_frame, text="取消", command=self._on_cancel)
|
|
||||||
cancel_btn.pack(side=tk.TOP)
|
|
||||||
|
|
||||||
def set_message(self, message: str):
|
|
||||||
self.message_label.config(text=message)
|
|
||||||
|
|
||||||
def set_detail(self, detail: str):
|
|
||||||
self.detail_label.config(text=detail)
|
|
||||||
|
|
||||||
def set_progress(self, value: float, maximum: float = 100):
|
|
||||||
self.progress_bar.config(mode='determinate')
|
|
||||||
self.progress_bar.config(maximum=maximum)
|
|
||||||
self.progress_bar.config(value=value)
|
|
||||||
|
|
||||||
def _on_cancel(self):
|
|
||||||
"""取消按钮点击"""
|
|
||||||
self.cancelled = True
|
|
||||||
if self.on_cancel_callback:
|
|
||||||
self.on_cancel_callback()
|
|
||||||
# 关闭对话框
|
|
||||||
import tkinter as tk
|
|
||||||
self.destroy() # tkinter 的 Toplevel 有 destroy 方法
|
|
||||||
|
|
||||||
|
|
||||||
# 便捷函数
|
|
||||||
def show_info(title: str, message: str, details: Optional[str] = None, parent=None):
|
|
||||||
"""显示信息对话框"""
|
|
||||||
handler = MessageHandler(parent)
|
|
||||||
handler.show_info(title, message, details)
|
|
||||||
|
|
||||||
|
|
||||||
def show_warning(title: str, message: str, details: Optional[str] = None, parent=None):
|
|
||||||
"""显示警告对话框"""
|
|
||||||
handler = MessageHandler(parent)
|
|
||||||
handler.show_warning(title, message, details)
|
|
||||||
|
|
||||||
|
|
||||||
def show_error(title: str, message: str, details: Optional[str] = None, exception: Optional[Exception] = None, parent=None):
|
|
||||||
"""显示错误对话框"""
|
|
||||||
handler = MessageHandler(parent)
|
|
||||||
handler.show_error(title, message, details, exception)
|
|
||||||
|
|
||||||
|
|
||||||
def ask_yes_no(title: str, message: str, parent=None, default: bool = True) -> bool:
|
|
||||||
"""询问是/否"""
|
|
||||||
handler = MessageHandler(parent)
|
|
||||||
return handler.ask_yes_no(title, message, default)
|
|
||||||
|
|
||||||
|
|
||||||
def ask_ok_cancel(title: str, message: str, parent=None, default: bool = True) -> bool:
|
|
||||||
"""询问确定/取消"""
|
|
||||||
handler = MessageHandler(parent)
|
|
||||||
return handler.ask_ok_cancel(title, message, default)
|
|
||||||
@@ -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,282 +0,0 @@
|
|||||||
"""
|
|
||||||
结果展示组件
|
|
||||||
|
|
||||||
用于展示处理结果,包括:
|
|
||||||
- OCR 文本展示
|
|
||||||
- AI 处理结果展示(纯文本格式)
|
|
||||||
- 一键复制功能
|
|
||||||
- 日志查看
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional, Callable
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# 尝试导入 tkinter,失败时使用 PyQt6
|
|
||||||
try:
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, scrolledtext, messagebox
|
|
||||||
HAS_TKINTER = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_TKINTER = False
|
|
||||||
from PyQt6.QtWidgets import (
|
|
||||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
|
|
||||||
QLabel, QPushButton, QTextEdit, QComboBox, QProgressBar
|
|
||||||
)
|
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal
|
|
||||||
from PyQt6.QtGui import QFont
|
|
||||||
|
|
||||||
from src.core.processor import ProcessResult, create_markdown_result, copy_to_clipboard
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ResultWidget(QWidget):
|
|
||||||
"""
|
|
||||||
结果展示组件 (PyQt6 版本)
|
|
||||||
|
|
||||||
显示处理结果,支持 Markdown 渲染和一键复制
|
|
||||||
"""
|
|
||||||
|
|
||||||
# 信号:内容改变
|
|
||||||
content_changed = pyqtSignal(str)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent,
|
|
||||||
copy_callback: Optional[Callable] = None,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
初始化结果展示组件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: 父容器
|
|
||||||
copy_callback: 复制按钮回调函数
|
|
||||||
**kwargs: 其他参数
|
|
||||||
"""
|
|
||||||
super().__init__(parent, **kwargs)
|
|
||||||
|
|
||||||
self.copy_callback = copy_callback
|
|
||||||
self.current_result: Optional[ProcessResult] = None
|
|
||||||
self.display_mode = "raw" # raw 或 markdown
|
|
||||||
|
|
||||||
self._create_ui()
|
|
||||||
|
|
||||||
def _create_ui(self):
|
|
||||||
"""创建 UI"""
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# 顶部工具栏
|
|
||||||
toolbar_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
# 结果类型选择
|
|
||||||
toolbar_layout.addWidget(QLabel("显示:"))
|
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QRadioButton, QButtonGroup
|
|
||||||
self.mode_group = QButtonGroup()
|
|
||||||
|
|
||||||
raw_btn = QRadioButton("原始文本")
|
|
||||||
raw_btn.setChecked(True)
|
|
||||||
raw_btn.clicked.connect(lambda: self._set_mode("raw"))
|
|
||||||
self.mode_group.addButton(raw_btn)
|
|
||||||
toolbar_layout.addWidget(raw_btn)
|
|
||||||
|
|
||||||
md_btn = QRadioButton("Markdown")
|
|
||||||
md_btn.clicked.connect(lambda: self._set_mode("markdown"))
|
|
||||||
self.mode_group.addButton(md_btn)
|
|
||||||
toolbar_layout.addWidget(md_btn)
|
|
||||||
|
|
||||||
toolbar_layout.addStretch()
|
|
||||||
|
|
||||||
# 右侧按钮
|
|
||||||
self.copy_button = QPushButton("复制")
|
|
||||||
self.copy_button.clicked.connect(self._on_copy)
|
|
||||||
toolbar_layout.addWidget(self.copy_button)
|
|
||||||
|
|
||||||
self.clear_button = QPushButton("清空")
|
|
||||||
self.clear_button.clicked.connect(self._on_clear)
|
|
||||||
toolbar_layout.addWidget(self.clear_button)
|
|
||||||
|
|
||||||
layout.addLayout(toolbar_layout)
|
|
||||||
|
|
||||||
# 主内容区域
|
|
||||||
self.text_widget = QTextEdit()
|
|
||||||
self.text_widget.setReadOnly(True)
|
|
||||||
self.text_widget.setFont(QFont("Consolas", 10))
|
|
||||||
layout.addWidget(self.text_widget)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
def _set_mode(self, mode: str):
|
|
||||||
"""设置显示模式"""
|
|
||||||
self.display_mode = mode
|
|
||||||
self._update_result_content()
|
|
||||||
|
|
||||||
def _on_copy(self):
|
|
||||||
"""复制按钮点击"""
|
|
||||||
content = self.text_widget.toPlainText().strip()
|
|
||||||
if not content:
|
|
||||||
if HAS_TKINTER:
|
|
||||||
from tkinter import messagebox
|
|
||||||
messagebox.showinfo("提示", "没有可复制的内容")
|
|
||||||
else:
|
|
||||||
from PyQt6.QtWidgets import QMessageBox
|
|
||||||
QMessageBox.information(self, "提示", "没有可复制的内容")
|
|
||||||
return
|
|
||||||
|
|
||||||
success = copy_to_clipboard(content)
|
|
||||||
if success:
|
|
||||||
self._update_status("已复制到剪贴板")
|
|
||||||
if self.copy_callback:
|
|
||||||
self.copy_callback(content)
|
|
||||||
else:
|
|
||||||
self._update_status("复制失败,请检查是否安装了 pyperclip")
|
|
||||||
|
|
||||||
def _on_clear(self):
|
|
||||||
"""清空按钮点击"""
|
|
||||||
self.text_widget.clear()
|
|
||||||
self.current_result = None
|
|
||||||
self._update_status("已清空")
|
|
||||||
|
|
||||||
def _update_result_content(self):
|
|
||||||
"""更新结果内容"""
|
|
||||||
if not self.current_result:
|
|
||||||
self.text_widget.clear()
|
|
||||||
return
|
|
||||||
|
|
||||||
mode = self.display_mode
|
|
||||||
if mode == "markdown":
|
|
||||||
content = self._get_markdown_content()
|
|
||||||
else:
|
|
||||||
content = self._get_raw_content()
|
|
||||||
|
|
||||||
self.text_widget.setPlainText(content)
|
|
||||||
|
|
||||||
def _get_markdown_content(self) -> str:
|
|
||||||
"""获取 Markdown 格式内容"""
|
|
||||||
if not self.current_result:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
ai_result = self.current_result.ai_result
|
|
||||||
ocr_text = self.current_result.ocr_result.full_text if self.current_result.ocr_result else ""
|
|
||||||
|
|
||||||
return create_markdown_result(ai_result, ocr_text)
|
|
||||||
|
|
||||||
def _get_raw_content(self) -> str:
|
|
||||||
"""获取原始文本内容"""
|
|
||||||
if not self.current_result:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
|
|
||||||
# OCR 文本
|
|
||||||
if self.current_result.ocr_result:
|
|
||||||
parts.append("## OCR 识别结果\n")
|
|
||||||
parts.append(self.current_result.ocr_result.full_text)
|
|
||||||
parts.append(f"\n\n置信度: {self.current_result.ocr_result.total_confidence:.2%}\n")
|
|
||||||
|
|
||||||
# AI 结果
|
|
||||||
if self.current_result.ai_result:
|
|
||||||
parts.append("\n## AI 处理结果\n")
|
|
||||||
parts.append(f"分类: {self.current_result.ai_result.category.value}\n")
|
|
||||||
parts.append(f"置信度: {self.current_result.ai_result.confidence:.2%}\n")
|
|
||||||
parts.append(f"标题: {self.current_result.ai_result.title}\n")
|
|
||||||
parts.append(f"标签: {', '.join(self.current_result.ai_result.tags)}\n")
|
|
||||||
parts.append(f"\n内容:\n{self.current_result.ai_result.content}\n")
|
|
||||||
|
|
||||||
# 处理信息
|
|
||||||
parts.append("\n## 处理信息\n")
|
|
||||||
parts.append(f"成功: {'是' if self.current_result.success else '否'}\n")
|
|
||||||
parts.append(f"耗时: {self.current_result.process_time:.2f}秒\n")
|
|
||||||
parts.append(f"已完成的步骤: {', '.join(self.current_result.steps_completed)}\n")
|
|
||||||
|
|
||||||
if self.current_result.warnings:
|
|
||||||
parts.append(f"\n警告:\n")
|
|
||||||
for warning in self.current_result.warnings:
|
|
||||||
parts.append(f" - {warning}\n")
|
|
||||||
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
def _update_status(self, message: str):
|
|
||||||
"""更新状态"""
|
|
||||||
# 这里可以发出信号让父窗口更新状态
|
|
||||||
pass
|
|
||||||
|
|
||||||
def set_result(self, result: ProcessResult):
|
|
||||||
"""
|
|
||||||
设置处理结果并显示
|
|
||||||
|
|
||||||
Args:
|
|
||||||
result: 处理结果
|
|
||||||
"""
|
|
||||||
self.current_result = result
|
|
||||||
self._update_result_content()
|
|
||||||
|
|
||||||
# 更新状态
|
|
||||||
if result.success:
|
|
||||||
status = f"处理成功 | 耗时 {result.process_time:.2f}秒"
|
|
||||||
else:
|
|
||||||
status = f"处理失败: {result.error_message or '未知错误'}"
|
|
||||||
|
|
||||||
self._update_status(status)
|
|
||||||
|
|
||||||
def append_log(self, level: str, message: str):
|
|
||||||
"""添加日志"""
|
|
||||||
# 简化版本:直接输出到控制台
|
|
||||||
from datetime import datetime
|
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
||||||
print(f"[{timestamp}] [{level}] {message}")
|
|
||||||
|
|
||||||
|
|
||||||
class QuickResultDialog:
|
|
||||||
"""
|
|
||||||
快速结果显示对话框 (PyQt6 版本)
|
|
||||||
|
|
||||||
用于快速显示处理结果,不集成到主界面
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent,
|
|
||||||
result: ProcessResult,
|
|
||||||
on_close: Optional[Callable] = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
初始化对话框
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: 父窗口
|
|
||||||
result: 处理结果
|
|
||||||
on_close: 关闭回调
|
|
||||||
"""
|
|
||||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QHBoxLayout, QLabel
|
|
||||||
|
|
||||||
super(QDialog, self).__init__(parent)
|
|
||||||
self.result = result
|
|
||||||
self.on_close = on_close
|
|
||||||
|
|
||||||
self.setWindowTitle("处理结果")
|
|
||||||
self.resize(600, 400)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# 显示结果
|
|
||||||
result_widget = ResultWidget(self)
|
|
||||||
result_widget.set_result(result)
|
|
||||||
layout.addWidget(result_widget)
|
|
||||||
|
|
||||||
# 底部按钮
|
|
||||||
button_layout = QHBoxLayout()
|
|
||||||
close_btn = QPushButton("关闭")
|
|
||||||
close_btn.clicked.connect(self._on_close)
|
|
||||||
button_layout.addWidget(close_btn)
|
|
||||||
button_layout.addStretch()
|
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
def _on_close(self):
|
|
||||||
"""关闭对话框"""
|
|
||||||
if self.on_close:
|
|
||||||
self.on_close()
|
|
||||||
self.accept()
|
|
||||||
@@ -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
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
CutThenThink 应用入口
|
CutThenThink - 极简截图上传工具
|
||||||
|
|
||||||
截图 → OCR解析 → AI理解并分类 → 形成备注和执行计划
|
截图 → 上传 → 分类浏览
|
||||||
|
|
||||||
|
核心功能:
|
||||||
|
- 截图(全屏/区域)
|
||||||
|
- 上传到云端
|
||||||
|
- 历史记录管理
|
||||||
|
- 可选 OCR 文字识别
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
def setup_path():
|
def setup_path():
|
||||||
"""设置Python路径,兼容开发和打包环境"""
|
"""设置Python路径,兼容开发和打包环境"""
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
# PyInstaller打包后的环境
|
# PyInstaller打包后的环境
|
||||||
# 在打包环境中,src目录会被解压到sys._MEIPASS
|
|
||||||
base_path = sys._MEIPASS
|
base_path = sys._MEIPASS
|
||||||
src_path = os.path.join(base_path, 'src')
|
src_path = os.path.join(base_path, 'src')
|
||||||
if os.path.exists(src_path):
|
if os.path.exists(src_path):
|
||||||
@@ -24,9 +30,22 @@ def setup_path():
|
|||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, current_dir)
|
sys.path.insert(0, current_dir)
|
||||||
|
|
||||||
|
|
||||||
setup_path()
|
setup_path()
|
||||||
|
|
||||||
from src.gui.main_window import main
|
|
||||||
|
def main():
|
||||||
|
"""应用入口"""
|
||||||
|
from src.gui.main_window import MainWindow
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setStyle("Fusion")
|
||||||
|
|
||||||
|
window = MainWindow()
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
"""
|
|
||||||
数据模型
|
|
||||||
"""
|
|
||||||
|
|
||||||
from src.models.database import (
|
|
||||||
BaseModel,
|
|
||||||
Record,
|
|
||||||
RecordCategory,
|
|
||||||
DatabaseManager,
|
|
||||||
db_manager,
|
|
||||||
init_database,
|
|
||||||
get_db,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'BaseModel',
|
|
||||||
'Record',
|
|
||||||
'RecordCategory',
|
|
||||||
'DatabaseManager',
|
|
||||||
'db_manager',
|
|
||||||
'init_database',
|
|
||||||
'get_db',
|
|
||||||
]
|
|
||||||
@@ -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