Initial commit: snapAna 截图智能整理工具
包含 FastAPI 后端、React 前端、队列/OCR/标签/待办等完整功能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# 项目数据
|
||||||
|
backend/.data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
Thumbs.db
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
# snapAna 进度文档
|
||||||
|
|
||||||
|
> 维护原则:完成或推进一个功能后在这里追加/更新条目;下次接手时先读这里再读代码。
|
||||||
|
|
||||||
|
## v0.1 (MVP) · 2026-05-22
|
||||||
|
|
||||||
|
### 后端 (FastAPI + SQLite)
|
||||||
|
|
||||||
|
- [x] 项目骨架:`backend/app/{core,models,schemas,providers,services,api}`,零迁移启动(`init_db` 自动建表 + 装配 FTS5 触发器)。
|
||||||
|
- [x] 数据模型:
|
||||||
|
- `screenshots`(含 `file_hash` 唯一、`captured_at`/`ai_status` 索引)
|
||||||
|
- `screenshot_meta` (1:1) + `screenshots_fts` (FTS5 over ocr_text/ai_title/ai_summary/ai_suggestion)
|
||||||
|
- `tags` + `screenshot_tags` 多对多
|
||||||
|
- `categories`(首次启动自动灌入 8 个默认分类)
|
||||||
|
- `todos`(AI 抽取的待办,状态:pending/doing/done/dropped)
|
||||||
|
- `jobs`(OCR/VLM/FULL 三种类型,含重试计数与最后错误)
|
||||||
|
- `watch_folders`(含 `is_sensitive` 字段,敏感目录禁上传)
|
||||||
|
- `settings`(键值表,存 Provider JSON)
|
||||||
|
- [x] 文件监听:`watcher.py` 使用 PollingObserver,新增/重命名都会触发;写入后等待文件大小稳定再入库。
|
||||||
|
- [x] 入库:sha256 去重,相同内容仅更新路径;同步生成 webp 缩略图。
|
||||||
|
- [x] 手动批量导入:`POST /api/watch/import`,后台任务扫描目录。
|
||||||
|
- [x] Provider 抽象:`OCRProvider`/`VLMProvider`;内置 `TesseractOCR` 与 `OpenAICompatVLM`(兼容 Ollama / GLM / MiniMax / OpenAI / OpenRouter)。
|
||||||
|
- [x] 分析流水线:`analyze_screenshot` -> OCR -> VLM -> 写 meta -> 解析 category/tags/todos;敏感目录禁止 base64 上传。
|
||||||
|
- [x] 异步 worker:`AnalyzeWorker` 单例,启动时复位上次 `running` 任务,asyncio.Semaphore 控制并发,失败自动按 `MAX_RETRIES` 重排。
|
||||||
|
- [x] REST 接口:
|
||||||
|
- `GET /api/screenshots`(分页 + 过滤 + FTS5 关键词)
|
||||||
|
- `GET /api/screenshots/{id}`、`PATCH`、`DELETE`、`POST .../reanalyze`、`/file`、`/thumb`
|
||||||
|
- `GET /api/screenshots/random`、`/stats`
|
||||||
|
- `GET/PATCH/DELETE /api/todos`、`GET /api/todos/summary`
|
||||||
|
- `GET/POST/PATCH/DELETE /api/watch/folders`、`POST /api/watch/import`、`GET /api/watch/queue`
|
||||||
|
- `GET /api/settings`(api_key 脱敏)、`GET/PUT /api/settings/providers/{key}`
|
||||||
|
- `GET/POST/PATCH/DELETE /api/settings/categories`、`GET /api/settings/tags`
|
||||||
|
|
||||||
|
### 前端 (React + Vite + Tailwind + react-window)
|
||||||
|
|
||||||
|
- [x] 路由:首页 / 库 / 随机 / 待办 / 设置
|
||||||
|
- [x] 首页:四宫格状态卡 + 每日回顾随机 6 张 + 分类分布
|
||||||
|
- [x] 库浏览:左侧筛选侧栏(关键词、分类、时间区间、排序、状态、收藏、热门标签) + react-window 虚拟滚动卡片网格 + 分页
|
||||||
|
- [x] 详情抽屉:原图 + AI 标题/摘要/建议 + OCR 文本(可复制)+ 分类切换 + 标签编辑 + 待办列表 + 元信息 + 收藏/重分析/移除
|
||||||
|
- [x] 随机展示页:单张大图 + 元信息侧栏 + 「再来一张」
|
||||||
|
- [x] 待办页:四个状态 Tab + 卡片列表 + 完成/搁置/重置 + 跳回原图
|
||||||
|
- [x] 设置页:
|
||||||
|
- 监听目录增删改 + 「重扫」
|
||||||
|
- OCR / VLM Provider 配置(OCR 支持 Tesseract,VLM 走 OpenAI 兼容)
|
||||||
|
- 分类管理(颜色 + 提示词)
|
||||||
|
|
||||||
|
### 工程化
|
||||||
|
|
||||||
|
- [x] `start-dev.ps1` 一键启动(自动建 venv + npm install + 并发启动后端与前端)
|
||||||
|
- [x] `.gitignore`、根 `README.md`、`backend/README.md`、`.env.example`
|
||||||
|
- [x] CORS、SQLite WAL / busy_timeout 等优化
|
||||||
|
|
||||||
|
## v0.1.6 · 待办/搜索/标签/排序
|
||||||
|
|
||||||
|
- [x] **待办**:分页 + 标题/备注关键词搜索
|
||||||
|
- [x] **库搜索**:FTS 前缀 + LIKE 子串(「三花」可匹配「三花猫」)+ 标签名模糊
|
||||||
|
- [x] **标签页** `/tags`:全部标签浏览、搜索、排序、点击跳转库筛选
|
||||||
|
- [x] **库排序**:导入时间、标题、文件大小等 8 种
|
||||||
|
- [x] **EXIF 地点**:入库读 GPS/拍摄时间,自动加 `地点:` 标签;重分析保留
|
||||||
|
|
||||||
|
## v0.1.5 · OCR 补跑队列
|
||||||
|
|
||||||
|
- [x] **OCR 专用任务** `JobKind.OCR`:仅重跑 OCR/视觉识文,不改动 AI 结果
|
||||||
|
- [x] **批量入队** `POST /api/watch/jobs/enqueue-ocr-failed`(AI 成功 + OCR 失败)
|
||||||
|
- [x] **单张补跑** `POST /api/screenshots/{id}/reocr`
|
||||||
|
- [x] Worker:FULL 任务优先于 OCR;OCR 失败不污染 `ai_status`
|
||||||
|
- [x] 队列页「OCR 待补」统计 + 补跑按钮;详情页「补跑 OCR」
|
||||||
|
|
||||||
|
## v0.1.4 · 队列详情页
|
||||||
|
|
||||||
|
- [x] **队列 API**:`GET /api/watch/jobs` 分页列出任务(含 `last_error`、缩略图、路径)
|
||||||
|
- [x] **队列操作**:`POST /api/watch/jobs/retry-failed` 重试失败任务;`POST /api/watch/jobs/reset-stale` 复位僵尸 RUNNING
|
||||||
|
- [x] **Worker 优化**:`status()` 改用 `GROUP BY` 计数;空闲时自动复位超时 RUNNING
|
||||||
|
- [x] **前端队列页**:侧栏「队列」入口;失败/运行中/排队/完成 Tab + 分页;展示完整错误信息;首页队列卡片可点击跳转
|
||||||
|
|
||||||
|
## v0.1.3 · UNC 网络路径 + Provider 测试
|
||||||
|
|
||||||
|
- [x] **UNC / 网络路径**:`path_utils.py` 规范化 `\\NAS\share\...`;入库、监听、原图读取不再用 `as_posix()` 破坏 UNC
|
||||||
|
- [x] **监听目录**:`POST /api/watch/validate-path` 测试路径可达性;设置页「测试路径」按钮
|
||||||
|
- [x] **Provider 测试**:`POST /api/settings/providers/{key}/test`;OCR(Tesseract/Paddle/HTTP/视觉)与视觉 AI 均支持连通性探活
|
||||||
|
|
||||||
|
## v0.1.2 · 多 OCR 引擎 + 识别模式
|
||||||
|
|
||||||
|
- [x] **OCR 引擎扩展**:Tesseract、PaddleOCR、HTTP API、视觉模型识文(OpenAI 兼容)
|
||||||
|
- [x] **文字识别方式**:设置页可选「传统 OCR / 视觉 AI / 混合」
|
||||||
|
- `ocr`:仅 OCR 引擎识文
|
||||||
|
- `vision`:视觉大模型识文(用 VLM 配置)
|
||||||
|
- `hybrid`:OCR 优先,失败时自动视觉识文,再交给 AI 分析
|
||||||
|
- [x] 新增 Provider:`ocr_vision.py`、`ocr_http.py`、`ocr_paddle.py`、`openai_vision_client.py`
|
||||||
|
- [x] API:`GET/PUT /api/settings/recognition-mode`
|
||||||
|
|
||||||
|
## v0.1.1 · 代码审核修复
|
||||||
|
|
||||||
|
- [x] **P1**:拆分 worker/analyze 事务。AI 调用全程在事务外执行,OCR/VLM 之间用三段短事务标记/写回,彻底消除「分析期间 SQLite 写锁」问题。
|
||||||
|
- [x] **P2**:详情页 PATCH `{ category_id: null }` 现在能真正清空分类(用 Pydantic `model_fields_set` 区分未传与显式 null),同时校验 category 存在性。
|
||||||
|
- [x] **P2**:`screenshots.category_id` 升级为 `ForeignKey(..., ondelete="SET NULL")`;`init_db()` 内置轻量迁移,旧库会清理悬空 `category_id`。
|
||||||
|
- [x] **P2**:跨线程唤醒 worker 改用 `loop.call_soon_threadsafe`(新接口 `worker.notify_threadsafe()`)。FastAPI 同步路由与 BackgroundTasks 都改走 threadsafe 入口。
|
||||||
|
- [x] **P3**:`GET /api/settings/providers/{key}` 返回新的 `ProviderConfigOut`,含 `api_key_mask` 字段;前端 `Settings.tsx` 去掉强转。
|
||||||
|
- [x] **P3**:`init_db()` 现在直接调用 `ensure_default_categories()`,首次访问设置/筛选页就能看到全部默认分类。
|
||||||
|
|
||||||
|
## 已知限制 & 后续可做
|
||||||
|
|
||||||
|
- 没接语义搜索 / CLIP 向量(在 plan 的「可扩展点」里预留思路)
|
||||||
|
- 没做 dedup(pHash),相同内容不同分辨率会算两条
|
||||||
|
- VLM 调用没做 RPS 限流,仅靠 `ANALYZE_CONCURRENCY`
|
||||||
|
- Tesseract 在 Windows 上需用户自行安装;可补一个一键检测脚本
|
||||||
|
- 前端筛选 Tag 只有第一个生效(多 Tag 交集后端未支持)
|
||||||
|
- 缩略图未做 LRU 清理,长时间运行需要手动清 `.data/thumbs/`
|
||||||
|
- SQLite 不支持 ALTER TABLE 加外键。已建过的旧库虽然不会再有悬空 `category_id`,但底层约束仍缺失;如果在意可以删 `.data/snapana.db` 让新表生效
|
||||||
|
|
||||||
|
## 操作回顾(首次部署)
|
||||||
|
|
||||||
|
1. `git clone` 或者直接进入仓库根目录
|
||||||
|
2. `.\start-dev.ps1` (首次约 1-2 分钟装依赖)
|
||||||
|
3. 浏览器打开 <http://127.0.0.1:5173>
|
||||||
|
4. 「设置」→ 添加监听目录(例如 `D:/Pictures/Screenshots`),勾选「敏感」可禁上传
|
||||||
|
5. 「设置」→ 配置 OCR / VLM Provider,保存
|
||||||
|
6. 等待首页右上角「队列」归零(或在「库」里看 `分析中 / 完成` 标记)
|
||||||
|
7. 享受筛选、随机、待办
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# snapAna · 截图智能整理
|
||||||
|
|
||||||
|
让 AI 帮你认识每一张截图。本地运行的 Web 应用:自动监听截图文件夹,提取文字、识别内容、给出标题/摘要/标签/待办,按时间和分类整理成可筛选、可随机展示的卡片库。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 监听一个或多个文件夹,新截图自动入库(含 OneDrive/同步盘的轮询兜底)
|
||||||
|
- 哈希去重,文件重命名/移动只更新路径
|
||||||
|
- 可插拔 Provider:Tesseract / PaddleOCR / HTTP OCR / 视觉模型识文 + OpenAI 兼容视觉 AI(Ollama / GLM / MiniMax / OpenAI…)
|
||||||
|
- 文字识别方式可选:传统 OCR、视觉 AI、混合(OCR 失败自动视觉识文)
|
||||||
|
- 单张图同时拿到结构化结果:标题 + 摘要 + 分类 + 标签 + 待办 + 建议
|
||||||
|
- SQLite + FTS5 全文搜索(OCR 文本 / AI 摘要 / AI 标题)
|
||||||
|
- 分类色块、标签云、收藏、日期范围、状态筛选;卡片网格虚拟滚动
|
||||||
|
- 随机展示页 + 首页「每日回顾」
|
||||||
|
- 待办清单:AI 自动抽取「待看 / 待读 / 待办」,可逐条标记完成
|
||||||
|
- 敏感目录黑名单:勾选后该目录内的截图不会上传云端 VLM
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
snapAna/
|
||||||
|
├── backend/ # FastAPI + SQLite + watchdog
|
||||||
|
│ ├── app/ # 应用代码
|
||||||
|
│ ├── run.py # 开发入口
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/ # Vite + React + Tailwind
|
||||||
|
│ └── src/
|
||||||
|
├── start-dev.ps1 # 一键启动脚本(Windows)
|
||||||
|
└── PROGRESS.md # 进度文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 一键启动(Windows PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 在仓库根目录
|
||||||
|
.\start-dev.ps1 # 首次会自动建 venv + 安装依赖
|
||||||
|
# 或显式重新装依赖
|
||||||
|
.\start-dev.ps1 -InstallDeps
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问:
|
||||||
|
|
||||||
|
- 前端:<http://127.0.0.1:5173>
|
||||||
|
- 后端 API:<http://127.0.0.1:8765/docs>
|
||||||
|
|
||||||
|
### 手动启动
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 后端
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 前端(另开一个终端)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置 AI Provider
|
||||||
|
|
||||||
|
进入「设置」页:
|
||||||
|
|
||||||
|
- **文字识别方式**:传统 OCR / 视觉 AI 识文 / 混合(推荐)
|
||||||
|
- **OCR 引擎**:`tesseract`(本地)、`paddleocr`(需 `pip install paddleocr`)、`http`(自定义 API)、`vision`(视觉模型纯识文)
|
||||||
|
- **视觉 AI 模型**:选 `openai_compat`,填入:
|
||||||
|
| 模型 | Base URL | Model 示例 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 本地 Ollama | `http://localhost:11434/v1` | `qwen2.5vl:7b` |
|
||||||
|
| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` |
|
||||||
|
| 智谱 GLM | `https://open.bigmodel.cn/api/paas/v4` | `glm-4v-flash` |
|
||||||
|
| MiniMax | `https://api.minimaxi.com/v1` | `MiniMax-VL-01` |
|
||||||
|
| OpenRouter | `https://openrouter.ai/api/v1` | `qwen/qwen2.5-vl-72b-instruct` |
|
||||||
|
|
||||||
|
保存后回到「设置」→「监听目录」,添加一个截图文件夹,系统会自动扫描并入库;右侧 worker 会按配置并发分析(默认并发数 2,可通过 `.env` 调整)。
|
||||||
|
|
||||||
|
## 数据与隐私
|
||||||
|
|
||||||
|
- 所有数据存在 `backend/.data/`:`snapana.db`(SQLite)+ `thumbs/`(缩略图缓存)
|
||||||
|
- 默认绑定 `127.0.0.1`,仅本机访问
|
||||||
|
- 标记为「敏感目录」的截图不会上传到云端 VLM;如果两个 Provider 都是本地,则永远离线
|
||||||
|
- 上传 VLM 之前会自动压缩到长边 1280 像素以节省成本/时延
|
||||||
|
|
||||||
|
## 开发提示
|
||||||
|
|
||||||
|
- 前端通过 Vite 反向代理 `/api/*` 到 `127.0.0.1:8765`
|
||||||
|
- 队列 / 监听器在 FastAPI lifespan 内启动,热重载会自动复用
|
||||||
|
- SQLAlchemy 模型与 FTS5 触发器在首次启动时由 `init_db()` 创建,无需额外迁移命令
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# snapAna 后端环境变量示例。复制为 .env 后按需修改。
|
||||||
|
DEBUG=false
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=8765
|
||||||
|
|
||||||
|
# 数据目录(默认 backend/.data)
|
||||||
|
# DATA_DIR=D:/snapAna-data
|
||||||
|
|
||||||
|
# 并发与重试
|
||||||
|
ANALYZE_CONCURRENCY=4
|
||||||
|
MAX_RETRIES=3
|
||||||
|
|
||||||
|
# 缩略图 / VLM 上传压缩
|
||||||
|
THUMB_SIZE=320
|
||||||
|
VLM_MAX_SIDE=1280
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# snapAna Backend
|
||||||
|
|
||||||
|
基于 FastAPI 的截图分析与分类后端。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate # Windows
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
可选依赖:
|
||||||
|
|
||||||
|
- 本地 OCR:安装 [Tesseract OCR](https://github.com/UB-Mannheim/tesseract/wiki) 并放入 PATH,下载 `chi_sim` 中文语言包。
|
||||||
|
- 本地 VLM:安装 [Ollama](https://ollama.com),拉取 `qwen2.5vl:7b` 等多模态模型。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy .env.example .env # 按需修改
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听 `http://127.0.0.1:8765`。OpenAPI 在 `/docs`。
|
||||||
|
|
||||||
|
## 数据目录
|
||||||
|
|
||||||
|
- SQLite 主库:`backend/.data/snapana.db`
|
||||||
|
- 缩略图缓存:`backend/.data/thumbs/`
|
||||||
|
|
||||||
|
可通过 `.env` 的 `DATA_DIR` 自定义。
|
||||||
|
|
||||||
|
## Provider 配置
|
||||||
|
|
||||||
|
在前端 `设置` 页或通过 `/api/settings/providers/{key}` 接口配置:
|
||||||
|
|
||||||
|
- OCR:`tesseract`(本地)或 `none`(仅靠 VLM 看图)
|
||||||
|
- VLM:`openai_compat`,`base_url` 形如:
|
||||||
|
- 本地 Ollama:`http://localhost:11434/v1`,model 例如 `qwen2.5vl:7b`
|
||||||
|
- 智谱 GLM:`https://open.bigmodel.cn/api/paas/v4`,model 例如 `glm-4v-flash`
|
||||||
|
- MiniMax:`https://api.minimaxi.com/v1`
|
||||||
|
- OpenAI:`https://api.openai.com/v1`,model 例如 `gpt-4o-mini`
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""snapAna 截图分析后端应用包。"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""API 通用依赖。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
def db_session() -> Iterator[Session]:
|
||||||
|
"""每请求一个会话。"""
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""截图列表 / 详情 / 随机 / 重新分析 / 文件流。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy import and_, func, or_, select, text
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.api.deps import db_session
|
||||||
|
from app.core.path_utils import is_accessible_file, path_from_storage
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.job import Job, JobKind, JobStatus
|
||||||
|
from app.models.meta import ScreenshotMeta
|
||||||
|
from app.models.screenshot import ProcessStatus, Screenshot
|
||||||
|
from app.models.tag import Tag
|
||||||
|
from app.services.search_utils import collect_search_ids
|
||||||
|
from app.schemas.screenshot import (
|
||||||
|
CategoryOut,
|
||||||
|
ScreenshotBrief,
|
||||||
|
ScreenshotDetail,
|
||||||
|
ScreenshotListResp,
|
||||||
|
ScreenshotUpdate,
|
||||||
|
TagOut,
|
||||||
|
TodoBrief,
|
||||||
|
)
|
||||||
|
from app.services.worker import worker
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/screenshots", tags=["screenshots"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_brief(shot: Screenshot, cat_map: dict[int, Category]) -> ScreenshotBrief:
|
||||||
|
"""ORM -> ScreenshotBrief。"""
|
||||||
|
return ScreenshotBrief(
|
||||||
|
id=shot.id,
|
||||||
|
path=shot.path,
|
||||||
|
width=shot.width,
|
||||||
|
height=shot.height,
|
||||||
|
captured_at=shot.captured_at,
|
||||||
|
thumb_url=f"/api/screenshots/{shot.id}/thumb" if shot.thumb_path else None,
|
||||||
|
ai_title=(shot.meta.ai_title if shot.meta else None),
|
||||||
|
ai_status=shot.ai_status,
|
||||||
|
ocr_status=shot.ocr_status,
|
||||||
|
is_favorite=bool(shot.is_favorite),
|
||||||
|
category=(
|
||||||
|
CategoryOut.model_validate(cat_map[shot.category_id])
|
||||||
|
if shot.category_id and shot.category_id in cat_map
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
tags=[TagOut.model_validate(t) for t in (shot.tags or [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _category_map(session: Session) -> dict[int, Category]:
|
||||||
|
return {c.id: c for c in session.scalars(select(Category)).all()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ScreenshotListResp)
|
||||||
|
def list_screenshots(
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
q: Optional[str] = Query(None, description="OCR+AI 全文搜索关键词"),
|
||||||
|
category_id: Optional[int] = Query(None),
|
||||||
|
tag: Optional[str] = Query(None),
|
||||||
|
date_from: Optional[datetime] = Query(None),
|
||||||
|
date_to: Optional[datetime] = Query(None),
|
||||||
|
favorite: Optional[bool] = Query(None),
|
||||||
|
status: Optional[str] = Query(None, description="ai_status 过滤"),
|
||||||
|
show_hidden: bool = Query(False),
|
||||||
|
sort: str = Query("captured_desc"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(40, ge=1, le=200),
|
||||||
|
) -> ScreenshotListResp:
|
||||||
|
"""主列表查询:支持时间/分类/标签/收藏/状态/搜索词。"""
|
||||||
|
stmt = select(Screenshot).options(
|
||||||
|
selectinload(Screenshot.meta),
|
||||||
|
selectinload(Screenshot.tags),
|
||||||
|
)
|
||||||
|
filters = []
|
||||||
|
if not show_hidden:
|
||||||
|
filters.append(Screenshot.is_hidden == 0)
|
||||||
|
if category_id is not None:
|
||||||
|
filters.append(Screenshot.category_id == category_id)
|
||||||
|
if date_from is not None:
|
||||||
|
filters.append(Screenshot.captured_at >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
filters.append(Screenshot.captured_at <= date_to)
|
||||||
|
if favorite is True:
|
||||||
|
filters.append(Screenshot.is_favorite == 1)
|
||||||
|
if status:
|
||||||
|
filters.append(Screenshot.ai_status == status)
|
||||||
|
if tag:
|
||||||
|
stmt = stmt.join(Screenshot.tags).where(Tag.name.ilike(f"%{tag}%"))
|
||||||
|
|
||||||
|
if q:
|
||||||
|
ids = collect_search_ids(session, q)
|
||||||
|
if not ids:
|
||||||
|
return ScreenshotListResp(items=[], total=0, page=page, size=size)
|
||||||
|
filters.append(Screenshot.id.in_(ids))
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
stmt = stmt.where(and_(*filters))
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
stmt = _apply_sort(stmt, sort)
|
||||||
|
|
||||||
|
total = session.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
rows = session.scalars(stmt.offset((page - 1) * size).limit(size)).unique().all()
|
||||||
|
|
||||||
|
cat_map = _category_map(session)
|
||||||
|
items = [_to_brief(r, cat_map) for r in rows]
|
||||||
|
return ScreenshotListResp(items=items, total=int(total), page=page, size=size)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sort(stmt, sort: str):
|
||||||
|
"""列表排序:时间 / 导入 / 标题 / 文件大小。"""
|
||||||
|
if sort == "captured_asc":
|
||||||
|
return stmt.order_by(Screenshot.captured_at.asc())
|
||||||
|
if sort == "imported_desc":
|
||||||
|
return stmt.order_by(Screenshot.imported_at.desc())
|
||||||
|
if sort == "imported_asc":
|
||||||
|
return stmt.order_by(Screenshot.imported_at.asc())
|
||||||
|
if sort == "title_asc":
|
||||||
|
return stmt.outerjoin(ScreenshotMeta).order_by(
|
||||||
|
ScreenshotMeta.ai_title.asc().nulls_last()
|
||||||
|
)
|
||||||
|
if sort == "title_desc":
|
||||||
|
return stmt.outerjoin(ScreenshotMeta).order_by(
|
||||||
|
ScreenshotMeta.ai_title.desc().nulls_last()
|
||||||
|
)
|
||||||
|
if sort == "size_desc":
|
||||||
|
return stmt.order_by(Screenshot.size.desc())
|
||||||
|
if sort == "size_asc":
|
||||||
|
return stmt.order_by(Screenshot.size.asc())
|
||||||
|
return stmt.order_by(Screenshot.captured_at.desc())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/random", response_model=list[ScreenshotBrief])
|
||||||
|
def random_screenshots(
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
n: int = Query(1, ge=1, le=20),
|
||||||
|
category_id: Optional[int] = Query(None),
|
||||||
|
) -> list[ScreenshotBrief]:
|
||||||
|
"""随机展示。"""
|
||||||
|
stmt = select(Screenshot).options(
|
||||||
|
selectinload(Screenshot.meta),
|
||||||
|
selectinload(Screenshot.tags),
|
||||||
|
).where(Screenshot.is_hidden == 0)
|
||||||
|
if category_id is not None:
|
||||||
|
stmt = stmt.where(Screenshot.category_id == category_id)
|
||||||
|
stmt = stmt.order_by(func.random()).limit(n)
|
||||||
|
rows = session.scalars(stmt).unique().all()
|
||||||
|
cat_map = _category_map(session)
|
||||||
|
return [_to_brief(r, cat_map) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def stats(session: Session = Depends(db_session)) -> dict:
|
||||||
|
"""汇总统计:总数、状态分布、按分类、按月份。"""
|
||||||
|
total = session.scalar(select(func.count(Screenshot.id))) or 0
|
||||||
|
by_status = {
|
||||||
|
st.value: session.scalar(
|
||||||
|
select(func.count(Screenshot.id)).where(Screenshot.ai_status == st.value)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
for st in ProcessStatus
|
||||||
|
}
|
||||||
|
by_category_rows = session.execute(
|
||||||
|
select(Category.id, Category.name, Category.color, func.count(Screenshot.id))
|
||||||
|
.join(Screenshot, Screenshot.category_id == Category.id, isouter=True)
|
||||||
|
.group_by(Category.id)
|
||||||
|
.order_by(func.count(Screenshot.id).desc())
|
||||||
|
).all()
|
||||||
|
by_category = [
|
||||||
|
{"id": r[0], "name": r[1], "color": r[2], "count": int(r[3] or 0)}
|
||||||
|
for r in by_category_rows
|
||||||
|
]
|
||||||
|
by_month_rows = session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT strftime('%Y-%m', captured_at) AS m, COUNT(1) AS c "
|
||||||
|
"FROM screenshots WHERE is_hidden=0 GROUP BY m ORDER BY m DESC LIMIT 36"
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
by_month = [{"month": r[0], "count": int(r[1])} for r in by_month_rows]
|
||||||
|
return {
|
||||||
|
"total": int(total),
|
||||||
|
"by_status": by_status,
|
||||||
|
"by_category": by_category,
|
||||||
|
"by_month": by_month,
|
||||||
|
"queue": _queue_summary(session),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_summary(session: Session) -> dict:
|
||||||
|
"""汇总 jobs 队列状态。"""
|
||||||
|
out: dict[str, int] = {}
|
||||||
|
for st in JobStatus:
|
||||||
|
out[st.value] = (
|
||||||
|
session.scalar(select(func.count(Job.id)).where(Job.status == st.value)) or 0
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{screenshot_id}", response_model=ScreenshotDetail)
|
||||||
|
def get_screenshot(
|
||||||
|
screenshot_id: int,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> ScreenshotDetail:
|
||||||
|
"""单张详情。"""
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
raise HTTPException(404, "Screenshot not found")
|
||||||
|
cat_map = _category_map(session)
|
||||||
|
brief = _to_brief(shot, cat_map)
|
||||||
|
meta = shot.meta
|
||||||
|
todos = [TodoBrief.model_validate(t) for t in shot.todos]
|
||||||
|
return ScreenshotDetail(
|
||||||
|
**brief.model_dump(),
|
||||||
|
file_url=f"/api/screenshots/{shot.id}/file",
|
||||||
|
size=shot.size,
|
||||||
|
ocr_text=(meta.ocr_text if meta else None),
|
||||||
|
ai_summary=(meta.ai_summary if meta else None),
|
||||||
|
ai_suggestion=(meta.ai_suggestion if meta else None),
|
||||||
|
todos=todos,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{screenshot_id}", response_model=ScreenshotDetail)
|
||||||
|
def update_screenshot(
|
||||||
|
screenshot_id: int,
|
||||||
|
payload: ScreenshotUpdate,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> ScreenshotDetail:
|
||||||
|
"""前端编辑:分类、收藏、隐藏、标签。"""
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
raise HTTPException(404, "Screenshot not found")
|
||||||
|
# 用 model_fields_set 区分「未传字段」与「显式传入 null」
|
||||||
|
# 这样前端 PATCH {"category_id": null} 可以真正清空分类
|
||||||
|
fields = payload.model_fields_set
|
||||||
|
if "category_id" in fields:
|
||||||
|
if payload.category_id is not None:
|
||||||
|
cat = session.get(Category, payload.category_id)
|
||||||
|
if cat is None:
|
||||||
|
raise HTTPException(400, "category not found")
|
||||||
|
shot.category_id = payload.category_id
|
||||||
|
if "is_favorite" in fields and payload.is_favorite is not None:
|
||||||
|
shot.is_favorite = 1 if payload.is_favorite else 0
|
||||||
|
if "is_hidden" in fields and payload.is_hidden is not None:
|
||||||
|
shot.is_hidden = 1 if payload.is_hidden else 0
|
||||||
|
if "tags" in fields and payload.tags is not None:
|
||||||
|
tag_objs = []
|
||||||
|
for name in payload.tags:
|
||||||
|
name = (name or "").strip()[:64]
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
tag = session.scalar(select(Tag).where(Tag.name == name))
|
||||||
|
if tag is None:
|
||||||
|
tag = Tag(name=name)
|
||||||
|
session.add(tag)
|
||||||
|
session.flush()
|
||||||
|
tag_objs.append(tag)
|
||||||
|
shot.tags = tag_objs
|
||||||
|
session.commit()
|
||||||
|
session.refresh(shot)
|
||||||
|
return get_screenshot(screenshot_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{screenshot_id}/reanalyze")
|
||||||
|
def reanalyze(
|
||||||
|
screenshot_id: int,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""加入队列重新分析。"""
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
raise HTTPException(404, "Screenshot not found")
|
||||||
|
shot.ai_status = ProcessStatus.PENDING.value
|
||||||
|
shot.ocr_status = ProcessStatus.PENDING.value
|
||||||
|
job = Job(screenshot_id=shot.id, kind=JobKind.FULL.value, status=JobStatus.PENDING.value)
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
# 同步路由跑在线程池,必须 threadsafe 唤醒事件循环
|
||||||
|
worker.notify_threadsafe()
|
||||||
|
return {"ok": True, "job_id": job.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{screenshot_id}/reocr")
|
||||||
|
def reocr(
|
||||||
|
screenshot_id: int,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""仅补跑 OCR,不重新调用 AI 分析。"""
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
raise HTTPException(404, "Screenshot not found")
|
||||||
|
active = session.scalar(
|
||||||
|
select(Job.id).where(
|
||||||
|
Job.screenshot_id == shot.id,
|
||||||
|
Job.kind == JobKind.OCR.value,
|
||||||
|
Job.status.in_((JobStatus.PENDING.value, JobStatus.RUNNING.value)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if active is not None:
|
||||||
|
return {"ok": True, "job_id": active, "message": "已有 OCR 任务在队列中"}
|
||||||
|
shot.ocr_status = ProcessStatus.PENDING.value
|
||||||
|
job = Job(
|
||||||
|
screenshot_id=shot.id,
|
||||||
|
kind=JobKind.OCR.value,
|
||||||
|
status=JobStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
worker.notify_threadsafe()
|
||||||
|
return {"ok": True, "job_id": job.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{screenshot_id}")
|
||||||
|
def delete_screenshot(
|
||||||
|
screenshot_id: int,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""删除记录(不删除原始文件)。"""
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
raise HTTPException(404, "Screenshot not found")
|
||||||
|
session.delete(shot)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{screenshot_id}/file")
|
||||||
|
def get_file(
|
||||||
|
screenshot_id: int,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""原图文件流。"""
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
raise HTTPException(404, "Screenshot not found")
|
||||||
|
p = path_from_storage(shot.path)
|
||||||
|
if not is_accessible_file(p):
|
||||||
|
raise HTTPException(404, "file missing")
|
||||||
|
return FileResponse(str(p))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{screenshot_id}/thumb")
|
||||||
|
def get_thumb(
|
||||||
|
screenshot_id: int,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""缩略图流。"""
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
raise HTTPException(404, "Screenshot not found")
|
||||||
|
if shot.thumb_path:
|
||||||
|
p = Path(shot.thumb_path)
|
||||||
|
if p.exists():
|
||||||
|
return FileResponse(str(p), media_type="image/webp")
|
||||||
|
# 兜底:返回原图
|
||||||
|
p = path_from_storage(shot.path)
|
||||||
|
if is_accessible_file(p):
|
||||||
|
return FileResponse(str(p))
|
||||||
|
raise HTTPException(404, "thumb missing")
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""设置接口:Provider 配置、分类、Tag。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import db_session
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.setting import (
|
||||||
|
DEFAULT_RECOGNITION_MODE,
|
||||||
|
KEY_OCR_PROVIDER,
|
||||||
|
KEY_RECOGNITION_MODE,
|
||||||
|
KEY_VLM_PROVIDER,
|
||||||
|
)
|
||||||
|
from app.models.screenshot import Screenshot
|
||||||
|
from app.models.tag import Tag
|
||||||
|
from app.providers import RECOGNITION_MODES
|
||||||
|
from app.schemas.common import (
|
||||||
|
CategoryIn,
|
||||||
|
ProviderConfig,
|
||||||
|
ProviderConfigOut,
|
||||||
|
ProviderTestResult,
|
||||||
|
RecognitionModeIn,
|
||||||
|
)
|
||||||
|
from app.services.provider_test import merge_provider_api_key, test_provider_config
|
||||||
|
from app.services.settings_store import all_settings, get_setting, set_setting
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_all(session: Session = Depends(db_session)) -> dict:
|
||||||
|
"""返回所有非敏感设置。api_key 字段做脱敏。"""
|
||||||
|
raw = all_settings(session)
|
||||||
|
for key in (KEY_OCR_PROVIDER, KEY_VLM_PROVIDER):
|
||||||
|
cfg = raw.get(key)
|
||||||
|
if isinstance(cfg, dict) and cfg.get("api_key"):
|
||||||
|
cfg["api_key_mask"] = _mask(cfg["api_key"])
|
||||||
|
cfg["api_key"] = ""
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _mask(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if len(value) <= 6:
|
||||||
|
return "*" * len(value)
|
||||||
|
return value[:3] + "*" * (len(value) - 6) + value[-3:]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers/{key}", response_model=ProviderConfigOut | None)
|
||||||
|
def get_provider(
|
||||||
|
key: str,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> ProviderConfigOut | None:
|
||||||
|
"""读取 Provider 配置:api_key 明文不外传,只给一个掩码用于 UI 提示。"""
|
||||||
|
if key not in (KEY_OCR_PROVIDER, KEY_VLM_PROVIDER):
|
||||||
|
raise HTTPException(400, "key must be ocr_provider or vlm_provider")
|
||||||
|
raw = get_setting(session, key, None)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
mask = _mask(raw.get("api_key", "") or "")
|
||||||
|
return ProviderConfigOut(
|
||||||
|
type=raw.get("type", ""),
|
||||||
|
base_url=raw.get("base_url"),
|
||||||
|
api_key="",
|
||||||
|
api_key_mask=mask or None,
|
||||||
|
model=raw.get("model"),
|
||||||
|
extra=raw.get("extra", {}) or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/providers/{key}")
|
||||||
|
def put_provider(
|
||||||
|
key: str,
|
||||||
|
cfg: ProviderConfig,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
if key not in (KEY_OCR_PROVIDER, KEY_VLM_PROVIDER):
|
||||||
|
raise HTTPException(400, "key must be ocr_provider or vlm_provider")
|
||||||
|
# 如果客户端没有传新的 api_key(空字符串),保留旧值
|
||||||
|
existing = get_setting(session, key, None)
|
||||||
|
payload = cfg.model_dump()
|
||||||
|
if (not payload.get("api_key")) and isinstance(existing, dict):
|
||||||
|
payload["api_key"] = existing.get("api_key", "")
|
||||||
|
set_setting(session, key, payload)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers/{key}/test", response_model=ProviderTestResult)
|
||||||
|
async def test_provider(
|
||||||
|
key: str,
|
||||||
|
cfg: ProviderConfig,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> ProviderTestResult:
|
||||||
|
"""测试 OCR / 视觉 AI Provider 连通性(使用当前表单配置,api_key 可留空沿用已保存值)。"""
|
||||||
|
if key not in (KEY_OCR_PROVIDER, KEY_VLM_PROVIDER):
|
||||||
|
raise HTTPException(400, "key must be ocr_provider or vlm_provider")
|
||||||
|
existing = get_setting(session, key, None)
|
||||||
|
merged = merge_provider_api_key(cfg, existing if isinstance(existing, dict) else None)
|
||||||
|
result = await test_provider_config(key, merged)
|
||||||
|
return ProviderTestResult(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recognition-mode")
|
||||||
|
def get_recognition_mode(session: Session = Depends(db_session)) -> dict:
|
||||||
|
"""读取文字识别策略:ocr / vision / hybrid。"""
|
||||||
|
mode = get_setting(session, KEY_RECOGNITION_MODE, DEFAULT_RECOGNITION_MODE)
|
||||||
|
if mode not in RECOGNITION_MODES:
|
||||||
|
mode = DEFAULT_RECOGNITION_MODE
|
||||||
|
return {"mode": mode, "options": list(RECOGNITION_MODES)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/recognition-mode")
|
||||||
|
def put_recognition_mode(
|
||||||
|
payload: RecognitionModeIn,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""保存文字识别策略。"""
|
||||||
|
if payload.mode not in RECOGNITION_MODES:
|
||||||
|
raise HTTPException(400, f"mode must be one of {RECOGNITION_MODES}")
|
||||||
|
set_setting(session, KEY_RECOGNITION_MODE, payload.mode)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True, "mode": payload.mode}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
def list_categories(session: Session = Depends(db_session)) -> list[dict]:
|
||||||
|
rows = session.scalars(select(Category).order_by(Category.id)).all()
|
||||||
|
return [
|
||||||
|
{"id": c.id, "name": c.name, "color": c.color, "prompt_hint": c.prompt_hint}
|
||||||
|
for c in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories")
|
||||||
|
def create_category(
|
||||||
|
payload: CategoryIn,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
exists = session.scalar(select(Category).where(Category.name == payload.name))
|
||||||
|
if exists is not None:
|
||||||
|
raise HTTPException(400, "category exists")
|
||||||
|
cat = Category(name=payload.name, color=payload.color, prompt_hint=payload.prompt_hint)
|
||||||
|
session.add(cat)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(cat)
|
||||||
|
return {"id": cat.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/categories/{cat_id}")
|
||||||
|
def update_category(
|
||||||
|
cat_id: int,
|
||||||
|
payload: CategoryIn,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
cat = session.get(Category, cat_id)
|
||||||
|
if cat is None:
|
||||||
|
raise HTTPException(404, "category not found")
|
||||||
|
cat.name = payload.name
|
||||||
|
cat.color = payload.color
|
||||||
|
cat.prompt_hint = payload.prompt_hint
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/categories/{cat_id}")
|
||||||
|
def delete_category(
|
||||||
|
cat_id: int,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
cat = session.get(Category, cat_id)
|
||||||
|
if cat is None:
|
||||||
|
raise HTTPException(404, "category not found")
|
||||||
|
session.delete(cat)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags")
|
||||||
|
def list_tags(
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
q: Optional[str] = Query(None, description="标签名关键词"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(200, ge=1, le=500),
|
||||||
|
sort: str = Query("count_desc", description="count_desc|count_asc|name_asc|name_desc"),
|
||||||
|
) -> dict:
|
||||||
|
"""标签列表(含使用次数),支持搜索与分页。"""
|
||||||
|
base = select(Tag.id)
|
||||||
|
if q:
|
||||||
|
base = base.where(Tag.name.ilike(f"%{q.strip()}%"))
|
||||||
|
total = session.scalar(select(func.count()).select_from(base.subquery())) or 0
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Tag.id, Tag.name, Tag.color, func.count(Screenshot.id))
|
||||||
|
.join(Tag.screenshots, isouter=True)
|
||||||
|
.group_by(Tag.id)
|
||||||
|
)
|
||||||
|
if q:
|
||||||
|
stmt = stmt.where(Tag.name.ilike(f"%{q.strip()}%"))
|
||||||
|
|
||||||
|
if sort == "count_asc":
|
||||||
|
stmt = stmt.order_by(func.count(Screenshot.id).asc())
|
||||||
|
elif sort == "name_asc":
|
||||||
|
stmt = stmt.order_by(Tag.name.asc())
|
||||||
|
elif sort == "name_desc":
|
||||||
|
stmt = stmt.order_by(Tag.name.desc())
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(func.count(Screenshot.id).desc())
|
||||||
|
|
||||||
|
rows = session.execute(stmt.offset((page - 1) * size).limit(size)).all()
|
||||||
|
items = [
|
||||||
|
{"id": r[0], "name": r[1], "color": r[2], "count": int(r[3] or 0)} for r in rows
|
||||||
|
]
|
||||||
|
return {"items": items, "total": int(total), "page": page, "size": size}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""待办清单接口。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import db_session
|
||||||
|
from app.models.todo import Todo, TodoStatus
|
||||||
|
from app.schemas.common import TodoUpdate
|
||||||
|
from app.schemas.screenshot import TodoBrief, TodoListResp
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/todos", tags=["todos"])
|
||||||
|
|
||||||
|
|
||||||
|
def _todo_filters(
|
||||||
|
status: Optional[str],
|
||||||
|
kind: Optional[str],
|
||||||
|
q: Optional[str],
|
||||||
|
) -> list:
|
||||||
|
"""构建待办筛选条件。"""
|
||||||
|
filters = []
|
||||||
|
if status:
|
||||||
|
filters.append(Todo.status == status)
|
||||||
|
if kind:
|
||||||
|
filters.append(Todo.kind == kind)
|
||||||
|
if q:
|
||||||
|
like = f"%{q.strip()}%"
|
||||||
|
filters.append(or_(Todo.title.ilike(like), Todo.note.ilike(like)))
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TodoListResp)
|
||||||
|
def list_todos(
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
kind: Optional[str] = Query(None),
|
||||||
|
q: Optional[str] = Query(None, description="标题/备注关键词"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(50, ge=1, le=200),
|
||||||
|
) -> TodoListResp:
|
||||||
|
"""按状态/类型/关键词分页查询。"""
|
||||||
|
filters = _todo_filters(status, kind, q)
|
||||||
|
base = select(Todo)
|
||||||
|
if filters:
|
||||||
|
base = base.where(and_(*filters))
|
||||||
|
|
||||||
|
total = session.scalar(select(func.count()).select_from(base.subquery())) or 0
|
||||||
|
rows = session.scalars(
|
||||||
|
base.order_by(Todo.created_at.desc()).offset((page - 1) * size).limit(size)
|
||||||
|
).all()
|
||||||
|
return TodoListResp(
|
||||||
|
items=[TodoBrief.model_validate(r) for r in rows],
|
||||||
|
total=int(total),
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def summary(session: Session = Depends(db_session)) -> dict:
|
||||||
|
"""各状态待办数量。"""
|
||||||
|
return {
|
||||||
|
st.value: session.scalar(select(func.count(Todo.id)).where(Todo.status == st.value)) or 0
|
||||||
|
for st in TodoStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{todo_id}", response_model=TodoBrief)
|
||||||
|
def update_todo(
|
||||||
|
todo_id: int,
|
||||||
|
payload: TodoUpdate,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> TodoBrief:
|
||||||
|
"""更新状态/标题/备注。"""
|
||||||
|
todo = session.get(Todo, todo_id)
|
||||||
|
if todo is None:
|
||||||
|
raise HTTPException(404, "Todo not found")
|
||||||
|
if payload.status is not None:
|
||||||
|
todo.status = payload.status
|
||||||
|
if payload.status == TodoStatus.DONE.value:
|
||||||
|
todo.completed_at = datetime.utcnow()
|
||||||
|
if payload.title is not None:
|
||||||
|
todo.title = payload.title
|
||||||
|
if payload.note is not None:
|
||||||
|
todo.note = payload.note
|
||||||
|
session.commit()
|
||||||
|
session.refresh(todo)
|
||||||
|
return TodoBrief.model_validate(todo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{todo_id}")
|
||||||
|
def delete_todo(
|
||||||
|
todo_id: int,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
todo = session.get(Todo, todo_id)
|
||||||
|
if todo is None:
|
||||||
|
raise HTTPException(404, "Todo not found")
|
||||||
|
session.delete(todo)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""监听目录的增删改、手动导入、分析队列。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.api.deps import db_session
|
||||||
|
from app.core.db import session_scope
|
||||||
|
from app.core.path_utils import (
|
||||||
|
count_files_sample,
|
||||||
|
is_accessible_dir,
|
||||||
|
normalize_user_path,
|
||||||
|
)
|
||||||
|
from app.models.job import Job, JobStatus
|
||||||
|
from app.models.screenshot import ProcessStatus, Screenshot
|
||||||
|
from app.models.watch_folder import WatchFolder
|
||||||
|
from app.schemas.common import WatchFolderIn, WatchFolderOut
|
||||||
|
from app.schemas.job import JobListResp, JobOut, JobRetryIn
|
||||||
|
from app.services.analyze import enqueue_ocr_jobs
|
||||||
|
from app.services.ingest import ingest_directory
|
||||||
|
from app.services.watcher import watcher_service
|
||||||
|
from app.services.worker import worker
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/watch", tags=["watch"])
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_folder_path(raw: str) -> str:
|
||||||
|
"""校验并规范化监听目录路径(含 UNC 网络路径)。"""
|
||||||
|
normalized = normalize_user_path(raw)
|
||||||
|
if not normalized:
|
||||||
|
raise HTTPException(400, "路径不能为空")
|
||||||
|
if not is_accessible_dir(normalized):
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"目录不存在或无法访问: {normalized}。"
|
||||||
|
"请确认 NAS 已挂载、有读权限,UNC 路径形如 \\\\服务器\\共享\\文件夹",
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/folders", response_model=list[WatchFolderOut])
|
||||||
|
def list_folders(session: Session = Depends(db_session)) -> list[WatchFolderOut]:
|
||||||
|
rows = session.scalars(select(WatchFolder).order_by(WatchFolder.id)).all()
|
||||||
|
return [WatchFolderOut.model_validate(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/folders", response_model=WatchFolderOut)
|
||||||
|
def add_folder(
|
||||||
|
payload: WatchFolderIn,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> WatchFolderOut:
|
||||||
|
"""新增监听目录,自动触发一次扫描入库。"""
|
||||||
|
normalized = _validate_folder_path(payload.path)
|
||||||
|
exists = session.scalar(select(WatchFolder).where(WatchFolder.path == normalized))
|
||||||
|
if exists is not None:
|
||||||
|
raise HTTPException(400, "目录已存在")
|
||||||
|
folder = WatchFolder(
|
||||||
|
path=normalized,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
recursive=payload.recursive,
|
||||||
|
is_sensitive=payload.is_sensitive,
|
||||||
|
)
|
||||||
|
session.add(folder)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(folder)
|
||||||
|
watcher_service.reload()
|
||||||
|
background.add_task(_scan_folder, normalized, payload.recursive)
|
||||||
|
return WatchFolderOut.model_validate(folder)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/folders/{folder_id}", response_model=WatchFolderOut)
|
||||||
|
def update_folder(
|
||||||
|
folder_id: int,
|
||||||
|
payload: WatchFolderIn,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> WatchFolderOut:
|
||||||
|
folder = session.get(WatchFolder, folder_id)
|
||||||
|
if folder is None:
|
||||||
|
raise HTTPException(404, "folder not found")
|
||||||
|
normalized = _validate_folder_path(payload.path)
|
||||||
|
folder.path = normalized
|
||||||
|
folder.enabled = payload.enabled
|
||||||
|
folder.recursive = payload.recursive
|
||||||
|
folder.is_sensitive = payload.is_sensitive
|
||||||
|
session.commit()
|
||||||
|
session.refresh(folder)
|
||||||
|
watcher_service.reload()
|
||||||
|
return WatchFolderOut.model_validate(folder)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/folders/{folder_id}")
|
||||||
|
def delete_folder(
|
||||||
|
folder_id: int,
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> dict:
|
||||||
|
folder = session.get(WatchFolder, folder_id)
|
||||||
|
if folder is None:
|
||||||
|
raise HTTPException(404, "folder not found")
|
||||||
|
session.delete(folder)
|
||||||
|
session.commit()
|
||||||
|
watcher_service.reload()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
def import_now(
|
||||||
|
payload: WatchFolderIn,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
) -> dict:
|
||||||
|
"""手动触发一次目录扫描(不一定要登记为监听)。"""
|
||||||
|
normalized = _validate_folder_path(payload.path)
|
||||||
|
background.add_task(_scan_folder, normalized, payload.recursive)
|
||||||
|
return {"ok": True, "message": "已在后台扫描"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate-path")
|
||||||
|
def validate_path(payload: WatchFolderIn) -> dict:
|
||||||
|
"""测试目录是否可访问(含 UNC 网络路径),返回抽样文件数。"""
|
||||||
|
normalized = _validate_folder_path(payload.path)
|
||||||
|
total, samples = count_files_sample(normalized, limit=3)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"path": normalized,
|
||||||
|
"sample_image_count": total,
|
||||||
|
"samples": samples,
|
||||||
|
"message": f"目录可访问,抽样发现约 {total}+ 张图片",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_folder(path: str, recursive: bool) -> None:
|
||||||
|
"""后台任务:扫描目录入库,再通知 worker。
|
||||||
|
|
||||||
|
BackgroundTasks 的同步函数运行在线程池中,必须用 threadsafe 入口
|
||||||
|
唤醒事件循环,否则 asyncio.Event.set() 会有竞态。
|
||||||
|
"""
|
||||||
|
with session_scope() as session:
|
||||||
|
ingest_directory(session, path, recursive=recursive)
|
||||||
|
worker.notify_threadsafe()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/queue")
|
||||||
|
async def queue_status() -> dict:
|
||||||
|
"""读取 worker 队列状态。"""
|
||||||
|
counts = await worker.status()
|
||||||
|
with session_scope() as session:
|
||||||
|
counts["ocr_retryable"] = (
|
||||||
|
session.scalar(
|
||||||
|
select(func.count(Screenshot.id)).where(
|
||||||
|
Screenshot.ocr_status == ProcessStatus.FAILED.value,
|
||||||
|
Screenshot.ai_status == ProcessStatus.DONE.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
counts["ocr_pending"] = (
|
||||||
|
session.scalar(
|
||||||
|
select(func.count(Job.id)).where(
|
||||||
|
Job.kind == "ocr",
|
||||||
|
Job.status == JobStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _job_to_out(job: Job, shot: Screenshot | None) -> JobOut:
|
||||||
|
"""ORM -> JobOut,附带截图摘要字段。"""
|
||||||
|
return JobOut(
|
||||||
|
id=job.id,
|
||||||
|
screenshot_id=job.screenshot_id,
|
||||||
|
kind=job.kind,
|
||||||
|
status=job.status,
|
||||||
|
retries=job.retries or 0,
|
||||||
|
last_error=job.last_error,
|
||||||
|
created_at=job.created_at,
|
||||||
|
started_at=job.started_at,
|
||||||
|
finished_at=job.finished_at,
|
||||||
|
thumb_url=(
|
||||||
|
f"/api/screenshots/{shot.id}/thumb" if shot and shot.thumb_path else None
|
||||||
|
),
|
||||||
|
path=shot.path if shot else None,
|
||||||
|
ai_title=(shot.meta.ai_title if shot and shot.meta else None),
|
||||||
|
ai_status=shot.ai_status if shot else None,
|
||||||
|
ocr_status=shot.ocr_status if shot else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs", response_model=JobListResp)
|
||||||
|
def list_jobs(
|
||||||
|
status: Optional[str] = Query(None, description="pending|running|done|failed"),
|
||||||
|
kind: Optional[str] = Query(None, description="full|ocr|vlm"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(50, ge=1, le=200),
|
||||||
|
session: Session = Depends(db_session),
|
||||||
|
) -> JobListResp:
|
||||||
|
"""分页列出分析任务,默认按 id 倒序(最新的在前)。"""
|
||||||
|
if status and status not in {s.value for s in JobStatus}:
|
||||||
|
raise HTTPException(400, f"无效 status: {status}")
|
||||||
|
|
||||||
|
base = select(Job)
|
||||||
|
if status:
|
||||||
|
base = base.where(Job.status == status)
|
||||||
|
if kind:
|
||||||
|
base = base.where(Job.kind == kind)
|
||||||
|
|
||||||
|
total = session.scalar(select(func.count()).select_from(base.subquery())) or 0
|
||||||
|
jobs = session.scalars(
|
||||||
|
base.order_by(Job.id.desc()).offset((page - 1) * size).limit(size)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
shot_ids = [j.screenshot_id for j in jobs]
|
||||||
|
shots: dict[int, Screenshot] = {}
|
||||||
|
if shot_ids:
|
||||||
|
rows = session.scalars(
|
||||||
|
select(Screenshot)
|
||||||
|
.where(Screenshot.id.in_(shot_ids))
|
||||||
|
.options(selectinload(Screenshot.meta))
|
||||||
|
).all()
|
||||||
|
shots = {s.id: s for s in rows}
|
||||||
|
|
||||||
|
items = [_job_to_out(j, shots.get(j.screenshot_id)) for j in jobs]
|
||||||
|
return JobListResp(items=items, total=total, page=page, size=size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/retry-failed")
|
||||||
|
def retry_failed_jobs(payload: JobRetryIn | None = None) -> dict:
|
||||||
|
"""将全部或指定 failed 任务重新排队。"""
|
||||||
|
job_ids = payload.job_ids if payload else None
|
||||||
|
count = worker.retry_failed(job_ids)
|
||||||
|
return {"ok": True, "count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/reset-stale")
|
||||||
|
def reset_stale_jobs(
|
||||||
|
minutes: int = Query(5, ge=1, le=1440),
|
||||||
|
reset_all: bool = Query(False, description="为 true 时复位全部 RUNNING"),
|
||||||
|
) -> dict:
|
||||||
|
"""复位僵尸 RUNNING 任务(worker 崩溃或未正常 finish 时)。"""
|
||||||
|
count = worker.reset_stale_running(minutes=minutes, reset_all=reset_all)
|
||||||
|
return {"ok": True, "count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/enqueue-ocr-failed")
|
||||||
|
def enqueue_ocr_failed(limit: int = Query(500, ge=1, le=5000)) -> dict:
|
||||||
|
"""为 AI 已成功但 OCR 失败的截图批量创建 OCR 补跑任务。"""
|
||||||
|
count = enqueue_ocr_jobs(limit=limit)
|
||||||
|
if count:
|
||||||
|
worker.notify()
|
||||||
|
return {"ok": True, "count": count}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""全局配置:路径、数据库、并发参数等。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
# 默认数据目录:放在 backend/.data 下,便于零配置启动
|
||||||
|
_BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
_DEFAULT_DATA_DIR = _BACKEND_ROOT / ".data"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""读取 .env 与环境变量的全局配置。"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=str(_BACKEND_ROOT / ".env"),
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 应用基础
|
||||||
|
app_name: str = "snapAna"
|
||||||
|
debug: bool = False
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 8765
|
||||||
|
|
||||||
|
# 数据目录
|
||||||
|
data_dir: Path = Field(default=_DEFAULT_DATA_DIR)
|
||||||
|
|
||||||
|
# 任务并发
|
||||||
|
analyze_concurrency: int = 4
|
||||||
|
max_retries: int = 3
|
||||||
|
|
||||||
|
# 缩略图
|
||||||
|
thumb_size: int = 320
|
||||||
|
vlm_max_side: int = 1280 # 上传 VLM 前压缩的长边像素
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
cors_origins: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db_path(self) -> Path:
|
||||||
|
"""SQLite 数据库文件路径。"""
|
||||||
|
return self.data_dir / "snapana.db"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db_url(self) -> str:
|
||||||
|
"""SQLAlchemy 连接串。"""
|
||||||
|
return f"sqlite:///{self.db_path.as_posix()}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumb_dir(self) -> Path:
|
||||||
|
"""缩略图缓存目录。"""
|
||||||
|
return self.data_dir / "thumbs"
|
||||||
|
|
||||||
|
def ensure_dirs(self) -> None:
|
||||||
|
"""确保所有运行期目录存在。"""
|
||||||
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.thumb_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
settings.ensure_dirs()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""数据库引擎、会话与初始化。
|
||||||
|
|
||||||
|
使用 SQLAlchemy 2.0 + SQLite。FTS5 虚拟表通过原生 SQL 创建,并配套触发器
|
||||||
|
让 OCR/AI 字段更新时自动同步到全文索引。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event, text
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""全局声明性 Base。"""
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.db_url,
|
||||||
|
echo=False,
|
||||||
|
future=True,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def _sqlite_pragmas(dbapi_connection, _connection_record):
|
||||||
|
"""启用外键、WAL、忙等待等 SQLite 优化项。"""
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=5000")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session() -> Iterator[Session]:
|
||||||
|
"""FastAPI 依赖注入:每个请求一个会话。"""
|
||||||
|
with SessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope() -> Iterator[Session]:
|
||||||
|
"""常规上下文管理:自动 commit/rollback。"""
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# FTS5 虚拟表与触发器 SQL(独立维护,便于以后调整字段)
|
||||||
|
_FTS_SCHEMA_SQL = [
|
||||||
|
"""
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS screenshots_fts
|
||||||
|
USING fts5(
|
||||||
|
ocr_text,
|
||||||
|
ai_title,
|
||||||
|
ai_summary,
|
||||||
|
ai_suggestion,
|
||||||
|
content='screenshot_meta',
|
||||||
|
content_rowid='screenshot_id',
|
||||||
|
tokenize='unicode61'
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS screenshot_meta_ai
|
||||||
|
AFTER INSERT ON screenshot_meta BEGIN
|
||||||
|
INSERT INTO screenshots_fts(rowid, ocr_text, ai_title, ai_summary, ai_suggestion)
|
||||||
|
VALUES (new.screenshot_id,
|
||||||
|
coalesce(new.ocr_text, ''),
|
||||||
|
coalesce(new.ai_title, ''),
|
||||||
|
coalesce(new.ai_summary, ''),
|
||||||
|
coalesce(new.ai_suggestion, ''));
|
||||||
|
END;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS screenshot_meta_ad
|
||||||
|
AFTER DELETE ON screenshot_meta BEGIN
|
||||||
|
INSERT INTO screenshots_fts(screenshots_fts, rowid, ocr_text, ai_title, ai_summary, ai_suggestion)
|
||||||
|
VALUES('delete', old.screenshot_id,
|
||||||
|
coalesce(old.ocr_text, ''),
|
||||||
|
coalesce(old.ai_title, ''),
|
||||||
|
coalesce(old.ai_summary, ''),
|
||||||
|
coalesce(old.ai_suggestion, ''));
|
||||||
|
END;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS screenshot_meta_au
|
||||||
|
AFTER UPDATE ON screenshot_meta BEGIN
|
||||||
|
INSERT INTO screenshots_fts(screenshots_fts, rowid, ocr_text, ai_title, ai_summary, ai_suggestion)
|
||||||
|
VALUES('delete', old.screenshot_id,
|
||||||
|
coalesce(old.ocr_text, ''),
|
||||||
|
coalesce(old.ai_title, ''),
|
||||||
|
coalesce(old.ai_summary, ''),
|
||||||
|
coalesce(old.ai_suggestion, ''));
|
||||||
|
INSERT INTO screenshots_fts(rowid, ocr_text, ai_title, ai_summary, ai_suggestion)
|
||||||
|
VALUES (new.screenshot_id,
|
||||||
|
coalesce(new.ocr_text, ''),
|
||||||
|
coalesce(new.ai_title, ''),
|
||||||
|
coalesce(new.ai_summary, ''),
|
||||||
|
coalesce(new.ai_suggestion, ''));
|
||||||
|
END;
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
"""启动时建表并装配 FTS5、灌入默认分类。"""
|
||||||
|
from app.models import register_all # noqa: F401
|
||||||
|
register_all()
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for stmt in _FTS_SCHEMA_SQL:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
_migrate_legacy_schema(conn)
|
||||||
|
|
||||||
|
# 启动期 seed 默认分类(即使首次启动也能在「设置」/筛选页看到分类)
|
||||||
|
from app.services.analyze import ensure_default_categories
|
||||||
|
ensure_default_categories()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy_schema(conn) -> None:
|
||||||
|
"""轻量迁移:旧版本的 screenshots.category_id 没有外键。
|
||||||
|
|
||||||
|
SQLite 不支持 ALTER TABLE 加外键,但删除分类时 ON DELETE SET NULL 失效
|
||||||
|
会导致悬空引用。检测到旧表时,主动用一次性 SQL 清理掉无效引用并打日志,
|
||||||
|
建议用户用「分类管理」页重建索引。
|
||||||
|
"""
|
||||||
|
pragma_rows = conn.execute(
|
||||||
|
text("PRAGMA foreign_key_list(screenshots)")
|
||||||
|
).fetchall()
|
||||||
|
has_cat_fk = any(row[2] == "categories" for row in pragma_rows)
|
||||||
|
if not has_cat_fk:
|
||||||
|
# 清理悬空 category_id,避免列表统计出错
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE screenshots SET category_id = NULL "
|
||||||
|
"WHERE category_id IS NOT NULL "
|
||||||
|
"AND category_id NOT IN (SELECT id FROM categories)"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""统一日志配置。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(debug: bool = False) -> None:
|
||||||
|
"""初始化根 logger 的格式与级别。"""
|
||||||
|
level = logging.DEBUG if debug else logging.INFO
|
||||||
|
fmt = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(logging.Formatter(fmt))
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers.clear()
|
||||||
|
root.addHandler(handler)
|
||||||
|
root.setLevel(level)
|
||||||
|
# 降低第三方库噪音
|
||||||
|
for noisy in ("watchdog", "httpx", "PIL"):
|
||||||
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
"""统一入口获取 logger。"""
|
||||||
|
return logging.getLogger(name)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""跨平台路径工具:重点兼容 Windows UNC 网络路径(\\\\NAS\\share\\...)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path, PureWindowsPath
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_user_path(raw: str) -> str:
|
||||||
|
"""规范化用户输入的路径,保留 UNC 反斜杠格式。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- \\\\JIULUGNAS\\personal_folder\\Photos -> 原样保留
|
||||||
|
- //JIULUGNAS/personal_folder/Photos -> 转为 UNC
|
||||||
|
- D:/Pictures/Screenshots -> D:\\Pictures\\Screenshots
|
||||||
|
"""
|
||||||
|
raw = (raw or "").strip().strip('"').strip("'")
|
||||||
|
if not raw:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# //server/share -> \\server\share
|
||||||
|
if raw.startswith("//") and not raw.startswith("///"):
|
||||||
|
raw = "\\\\" + raw.lstrip("/").replace("/", "\\")
|
||||||
|
elif raw.startswith("\\\\"):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raw = raw.replace("/", "\\")
|
||||||
|
return str(PureWindowsPath(raw))
|
||||||
|
|
||||||
|
return str(Path(raw).expanduser())
|
||||||
|
|
||||||
|
|
||||||
|
def path_from_storage(stored: str) -> Path:
|
||||||
|
"""从数据库读出的路径转为 Path(修复历史 as_posix 导致的 //NAS/...)。"""
|
||||||
|
if not stored:
|
||||||
|
return Path(stored)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# 历史数据://JIULUGNAS/foo/bar -> \\JIULUGNAS\foo\bar
|
||||||
|
if stored.startswith("//") and not stored.startswith("///"):
|
||||||
|
stored = "\\\\" + stored.lstrip("/").replace("/", "\\")
|
||||||
|
return Path(stored)
|
||||||
|
|
||||||
|
|
||||||
|
def path_to_storage(path: Path | str) -> str:
|
||||||
|
"""写入数据库 / 比较用的路径字符串;Windows 下保留反斜杠。"""
|
||||||
|
if isinstance(path, Path):
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return str(path)
|
||||||
|
return path.as_posix()
|
||||||
|
return normalize_user_path(str(path)) if sys.platform == "win32" else str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def is_accessible_dir(path: str | Path) -> bool:
|
||||||
|
"""目录是否可访问(UNC / 本地均适用)。"""
|
||||||
|
try:
|
||||||
|
return os.path.isdir(str(path))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_accessible_file(path: str | Path) -> bool:
|
||||||
|
"""文件是否可访问。"""
|
||||||
|
try:
|
||||||
|
return os.path.isfile(str(path))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def path_is_under(parent: str | Path, child: str | Path) -> bool:
|
||||||
|
"""判断 child 是否在 parent 目录下(用于敏感目录检测)。"""
|
||||||
|
try:
|
||||||
|
parent_norm = os.path.normcase(os.path.normpath(str(parent)))
|
||||||
|
child_norm = os.path.normcase(os.path.normpath(str(child)))
|
||||||
|
if not parent_norm.endswith(os.sep):
|
||||||
|
parent_norm += os.sep
|
||||||
|
return child_norm.startswith(parent_norm) or child_norm == parent_norm.rstrip(os.sep)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def count_files_sample(root: str | Path, limit: int = 5) -> tuple[int, list[str]]:
|
||||||
|
"""快速抽样统计目录下图片数量(网络路径可能较慢,limit 控制遍历深度)。"""
|
||||||
|
from app.services.thumbnail import is_supported
|
||||||
|
|
||||||
|
root_p = path_from_storage(str(root)) if isinstance(root, str) else root
|
||||||
|
total = 0
|
||||||
|
samples: list[str] = []
|
||||||
|
try:
|
||||||
|
for dirpath, _, filenames in os.walk(str(root_p)):
|
||||||
|
for name in filenames:
|
||||||
|
p = Path(dirpath) / name
|
||||||
|
if not is_supported(p):
|
||||||
|
continue
|
||||||
|
total += 1
|
||||||
|
if len(samples) < limit:
|
||||||
|
samples.append(path_to_storage(p))
|
||||||
|
if total >= 1000:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return total, samples
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""FastAPI 应用入口。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api import screenshots, settings_api, todos, watch
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.db import init_db
|
||||||
|
from app.core.logger import get_logger, setup_logging
|
||||||
|
from app.services.watcher import watcher_service
|
||||||
|
from app.services.worker import worker
|
||||||
|
|
||||||
|
|
||||||
|
setup_logging(settings.debug)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI): # noqa: ARG001
|
||||||
|
"""启动时初始化 DB、启动监听器与分析 worker。"""
|
||||||
|
init_db()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
async def notify() -> None:
|
||||||
|
worker.notify()
|
||||||
|
|
||||||
|
watcher_service.start(loop, notify)
|
||||||
|
await worker.start()
|
||||||
|
logger.info("snapAna 启动完成 @ http://%s:%d", settings.host, settings.port)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
watcher_service.stop()
|
||||||
|
await worker.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="snapAna", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(screenshots.router)
|
||||||
|
app.include_router(todos.router)
|
||||||
|
app.include_router(settings_api.router)
|
||||||
|
app.include_router(watch.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict:
|
||||||
|
"""健康检查。"""
|
||||||
|
return {"status": "ok", "version": "0.1.0"}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""SQLAlchemy 模型集中注册入口。"""
|
||||||
|
|
||||||
|
|
||||||
|
def register_all() -> None:
|
||||||
|
"""显式导入以触发模型注册到 Base.metadata。"""
|
||||||
|
from . import screenshot # noqa: F401
|
||||||
|
from . import meta # noqa: F401
|
||||||
|
from . import tag # noqa: F401
|
||||||
|
from . import category # noqa: F401
|
||||||
|
from . import todo # noqa: F401
|
||||||
|
from . import job # noqa: F401
|
||||||
|
from . import watch_folder # noqa: F401
|
||||||
|
from . import setting # noqa: F401
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""截图分类。预置常见类目,AI 命中即可写回。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
"""截图分类。"""
|
||||||
|
|
||||||
|
__tablename__ = "categories"
|
||||||
|
__table_args__ = (UniqueConstraint("name", name="uq_categories_name"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
color: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
prompt_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
# 首次启动时灌入的默认分类
|
||||||
|
DEFAULT_CATEGORIES: list[dict[str, str | None]] = [
|
||||||
|
{"name": "知识技术", "color": "#3b82f6", "prompt_hint": "技术文章、代码、教程、文档截图"},
|
||||||
|
{"name": "梗图幽默", "color": "#f59e0b", "prompt_hint": "搞笑图、表情包、梗图"},
|
||||||
|
{"name": "小说文字", "color": "#8b5cf6", "prompt_hint": "长段文字、小说阅读、电子书"},
|
||||||
|
{"name": "聊天记录", "color": "#10b981", "prompt_hint": "微信/QQ/Slack 等聊天截图"},
|
||||||
|
{"name": "UI 设计", "color": "#ec4899", "prompt_hint": "界面设计、网页/App 灵感参考"},
|
||||||
|
{"name": "生活记录", "color": "#22c55e", "prompt_hint": "日常照片、生活记录、票据"},
|
||||||
|
{"name": "购物商品", "color": "#ef4444", "prompt_hint": "商品截图、价格、订单"},
|
||||||
|
{"name": "其他", "color": "#6b7280", "prompt_hint": "无法明确归类"},
|
||||||
|
]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""分析任务队列:持久化到 SQLite,断电可恢复。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class JobKind(str, Enum):
|
||||||
|
"""任务种类。"""
|
||||||
|
|
||||||
|
OCR = "ocr"
|
||||||
|
VLM = "vlm"
|
||||||
|
FULL = "full" # OCR + VLM 一条龙
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(str, Enum):
|
||||||
|
"""任务运行状态。"""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
DONE = "done"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class Job(Base):
|
||||||
|
"""单条分析任务记录。"""
|
||||||
|
|
||||||
|
__tablename__ = "jobs"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_jobs_status", "status"),
|
||||||
|
Index("ix_jobs_kind_status", "kind", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
screenshot_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("screenshots.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), default=JobKind.FULL.value)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default=JobStatus.PENDING.value)
|
||||||
|
retries: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""截图的 OCR / AI 元信息。与 screenshot 1:1。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotMeta(Base):
|
||||||
|
"""OCR 文本 + AI 结构化结果。"""
|
||||||
|
|
||||||
|
__tablename__ = "screenshot_meta"
|
||||||
|
|
||||||
|
screenshot_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("screenshots.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ocr_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ai_title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ai_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ai_suggestion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ai_raw_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 完整原始 JSON
|
||||||
|
|
||||||
|
screenshot = relationship("Screenshot", back_populates="meta")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""截图主表与处理状态。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessStatus(str, Enum):
|
||||||
|
"""处理流水线的状态枚举。"""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
DONE = "done"
|
||||||
|
FAILED = "failed"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
class Screenshot(Base):
|
||||||
|
"""截图文件主记录。"""
|
||||||
|
|
||||||
|
__tablename__ = "screenshots"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("file_hash", name="uq_screenshots_file_hash"),
|
||||||
|
Index("ix_screenshots_captured_at", "captured_at"),
|
||||||
|
Index("ix_screenshots_ai_status", "ai_status"),
|
||||||
|
Index("ix_screenshots_category_id", "category_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
file_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
width: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
height: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
size: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||||
|
|
||||||
|
captured_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
imported_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
thumb_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
|
||||||
|
ocr_status: Mapped[str] = mapped_column(String(16), default=ProcessStatus.PENDING.value)
|
||||||
|
ai_status: Mapped[str] = mapped_column(String(16), default=ProcessStatus.PENDING.value)
|
||||||
|
|
||||||
|
# AI 写回的分类:外键 + SET NULL,删除分类时自动把引用置空
|
||||||
|
category_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("categories.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
is_favorite: Mapped[int] = mapped_column(Integer, default=0) # 0/1,便于 SQLite 索引
|
||||||
|
is_hidden: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
meta = relationship(
|
||||||
|
"ScreenshotMeta",
|
||||||
|
back_populates="screenshot",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
tags = relationship(
|
||||||
|
"Tag",
|
||||||
|
secondary="screenshot_tags",
|
||||||
|
back_populates="screenshots",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
todos = relationship(
|
||||||
|
"Todo",
|
||||||
|
back_populates="screenshot",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""键值设置:Provider 配置等以 JSON 形式存储。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base):
|
||||||
|
"""通用键值设置。"""
|
||||||
|
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
value_json: Mapped[str] = mapped_column(Text, nullable=False, default="null")
|
||||||
|
|
||||||
|
|
||||||
|
# 设置键名常量
|
||||||
|
KEY_OCR_PROVIDER = "ocr_provider"
|
||||||
|
KEY_VLM_PROVIDER = "vlm_provider"
|
||||||
|
KEY_RECOGNITION_MODE = "recognition_mode" # ocr | vision | hybrid
|
||||||
|
KEY_CATEGORY_HINT = "category_hint"
|
||||||
|
|
||||||
|
# 默认识别模式:混合(OCR 文本 + 视觉 AI 联合分析)
|
||||||
|
DEFAULT_RECOGNITION_MODE = "hybrid"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""标签与多对多关联。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Column, ForeignKey, Integer, String, Table, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
screenshot_tags = Table(
|
||||||
|
"screenshot_tags",
|
||||||
|
Base.metadata,
|
||||||
|
Column(
|
||||||
|
"screenshot_id",
|
||||||
|
Integer,
|
||||||
|
ForeignKey("screenshots.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
"tag_id",
|
||||||
|
Integer,
|
||||||
|
ForeignKey("tags.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(Base):
|
||||||
|
"""用户/AI 共享的自由标签。"""
|
||||||
|
|
||||||
|
__tablename__ = "tags"
|
||||||
|
__table_args__ = (UniqueConstraint("name", name="uq_tags_name"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
color: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
|
||||||
|
screenshots = relationship(
|
||||||
|
"Screenshot",
|
||||||
|
secondary=screenshot_tags,
|
||||||
|
back_populates="tags",
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""AI 抽取的待办(待看/待读/待办)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TodoStatus(str, Enum):
|
||||||
|
"""待办状态。"""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
DOING = "doing"
|
||||||
|
DONE = "done"
|
||||||
|
DROPPED = "dropped"
|
||||||
|
|
||||||
|
|
||||||
|
class Todo(Base):
|
||||||
|
"""AI 从截图中抽取的待办项。"""
|
||||||
|
|
||||||
|
__tablename__ = "todos"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_todos_status", "status"),
|
||||||
|
Index("ix_todos_screenshot_id", "screenshot_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
screenshot_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("screenshots.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
kind: Mapped[str | None] = mapped_column(String(32), nullable=True) # 待看/待读/待办等
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default=TodoStatus.PENDING.value)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
screenshot = relationship("Screenshot", back_populates="todos")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""被监听的截图目录列表。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WatchFolder(Base):
|
||||||
|
"""监听的截图目录。"""
|
||||||
|
|
||||||
|
__tablename__ = "watch_folders"
|
||||||
|
__table_args__ = (UniqueConstraint("path", name="uq_watch_folders_path"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
recursive: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
is_sensitive: Mapped[bool] = mapped_column(Boolean, default=False) # 是否禁止上传云端
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Provider 工厂,按设置中的 type 字段实例化。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.schemas.common import ProviderConfig
|
||||||
|
|
||||||
|
from .base import OCRProvider, VLMProvider
|
||||||
|
from .ocr_http import HttpOCR
|
||||||
|
from .ocr_paddle import PaddleOCRProvider
|
||||||
|
from .ocr_tesseract import TesseractOCR
|
||||||
|
from .ocr_vision import VisionOCR
|
||||||
|
from .vlm_openai import OpenAICompatVLM
|
||||||
|
|
||||||
|
# OCR Provider 类型常量
|
||||||
|
OCR_TYPES = ("tesseract", "paddleocr", "http", "vision", "none")
|
||||||
|
VLM_TYPES = ("openai_compat", "none")
|
||||||
|
RECOGNITION_MODES = ("ocr", "vision", "hybrid")
|
||||||
|
|
||||||
|
|
||||||
|
def build_ocr_provider(
|
||||||
|
cfg: ProviderConfig | None,
|
||||||
|
*,
|
||||||
|
allow_upload: bool = True,
|
||||||
|
) -> Optional[OCRProvider]:
|
||||||
|
"""根据配置构造传统 OCR / 视觉 OCR Provider。"""
|
||||||
|
if cfg is None or cfg.type in ("", "none", "disabled"):
|
||||||
|
return None
|
||||||
|
if cfg.type == "tesseract":
|
||||||
|
return TesseractOCR(
|
||||||
|
lang=cfg.extra.get("lang", "chi_sim+eng"),
|
||||||
|
cmd=cfg.extra.get("cmd"),
|
||||||
|
)
|
||||||
|
if cfg.type == "paddleocr":
|
||||||
|
return PaddleOCRProvider(lang=cfg.extra.get("lang", "ch"))
|
||||||
|
if cfg.type == "http":
|
||||||
|
if not cfg.base_url:
|
||||||
|
raise ValueError("HTTP OCR 需要配置 base_url")
|
||||||
|
return HttpOCR(
|
||||||
|
base_url=cfg.base_url,
|
||||||
|
api_key=cfg.api_key or "",
|
||||||
|
text_path=str(cfg.extra.get("text_path", "text")),
|
||||||
|
headers=cfg.extra.get("headers") if isinstance(cfg.extra.get("headers"), dict) else None,
|
||||||
|
timeout=float(cfg.extra.get("timeout", 30)),
|
||||||
|
)
|
||||||
|
if cfg.type == "vision":
|
||||||
|
return build_vision_ocr(cfg, allow_upload=allow_upload)
|
||||||
|
raise ValueError(f"未知 OCR Provider 类型: {cfg.type}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_vision_ocr(
|
||||||
|
cfg: ProviderConfig | None,
|
||||||
|
*,
|
||||||
|
allow_upload: bool = True,
|
||||||
|
) -> Optional[VisionOCR]:
|
||||||
|
"""从 ProviderConfig 构造视觉 OCR(可与 VLM 共用同一套接口配置)。"""
|
||||||
|
if cfg is None or cfg.type in ("", "none", "disabled"):
|
||||||
|
return None
|
||||||
|
base_url = cfg.base_url or "http://localhost:11434/v1"
|
||||||
|
model = cfg.model or "qwen2.5vl:7b"
|
||||||
|
return VisionOCR(
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=cfg.api_key or "",
|
||||||
|
model=model,
|
||||||
|
timeout=float(cfg.extra.get("timeout", 60)),
|
||||||
|
allow_upload=allow_upload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_vlm_provider(cfg: ProviderConfig | None) -> Optional[VLMProvider]:
|
||||||
|
"""根据配置构造 VLM Provider(AI 分类/摘要/标签)。"""
|
||||||
|
if cfg is None or cfg.type in ("", "none", "disabled"):
|
||||||
|
return None
|
||||||
|
if cfg.type in ("openai_compat", "openai", "ollama", "glm", "minimax", "moonshot", "vision"):
|
||||||
|
return OpenAICompatVLM(
|
||||||
|
base_url=cfg.base_url or "http://localhost:11434/v1",
|
||||||
|
api_key=cfg.api_key or "",
|
||||||
|
model=cfg.model or "gpt-4o-mini",
|
||||||
|
timeout=float(cfg.extra.get("timeout", 60)),
|
||||||
|
)
|
||||||
|
raise ValueError(f"未知 VLM Provider 类型: {cfg.type}")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""OCR / VLM Provider 抽象接口。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VLMResult:
|
||||||
|
"""VLM 结构化分析结果。"""
|
||||||
|
|
||||||
|
title: str = ""
|
||||||
|
summary: str = ""
|
||||||
|
category: str | None = None
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
todos: list[dict[str, str]] = field(default_factory=list) # [{title, kind, note}]
|
||||||
|
suggestion: str = ""
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OCRProvider(ABC):
|
||||||
|
"""OCR 接口:输入图片路径,返回文本。"""
|
||||||
|
|
||||||
|
name: str = "ocr"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def recognize(self, image_path: Path) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class VLMProvider(ABC):
|
||||||
|
"""多模态接口:根据图片 + OCR 文本生成结构化分析。"""
|
||||||
|
|
||||||
|
name: str = "vlm"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def analyze(
|
||||||
|
self,
|
||||||
|
image_path: Path,
|
||||||
|
ocr_text: str,
|
||||||
|
categories: list[str],
|
||||||
|
allow_upload: bool,
|
||||||
|
) -> VLMResult:
|
||||||
|
...
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""通用 HTTP OCR:向自定义 REST 接口 POST 图片并解析文本。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import OCRProvider
|
||||||
|
|
||||||
|
|
||||||
|
class HttpOCR(OCRProvider):
|
||||||
|
"""POST JSON {"image_base64": "..."} 到指定 URL,从响应 JSON 取文本。
|
||||||
|
|
||||||
|
extra 配置项:
|
||||||
|
- text_path: 点分路径,如 "data.text" 或 "result",默认 "text"
|
||||||
|
- headers: 额外请求头 dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "http"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str = "",
|
||||||
|
text_path: str = "text",
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.text_path = text_path
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
async def recognize(self, image_path: Path) -> str:
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
encoded = base64.b64encode(f.read()).decode("ascii")
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json", **self.headers}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
|
payload = {"image_base64": encoded, "image": encoded}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
resp = await client.post(self.base_url, json=payload, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
return str(_dig(data, self.text_path) or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _dig(obj: Any, path: str) -> Any:
|
||||||
|
"""按点分路径从嵌套 dict 取值。"""
|
||||||
|
cur = obj
|
||||||
|
for part in path.split("."):
|
||||||
|
if not isinstance(cur, dict):
|
||||||
|
return None
|
||||||
|
cur = cur.get(part)
|
||||||
|
return cur
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""PaddleOCR 本地 OCR(可选依赖)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .base import OCRProvider
|
||||||
|
|
||||||
|
|
||||||
|
class PaddleOCRProvider(OCRProvider):
|
||||||
|
"""通过 PaddleOCR 本地识文。需 pip install paddleocr paddlepaddle。"""
|
||||||
|
|
||||||
|
name = "paddleocr"
|
||||||
|
|
||||||
|
def __init__(self, lang: str = "ch") -> None:
|
||||||
|
self.lang = lang
|
||||||
|
self._engine = None
|
||||||
|
|
||||||
|
async def recognize(self, image_path: Path) -> str:
|
||||||
|
return await asyncio.to_thread(self._sync_recognize, image_path)
|
||||||
|
|
||||||
|
def _sync_recognize(self, image_path: Path) -> str:
|
||||||
|
try:
|
||||||
|
from paddleocr import PaddleOCR # type: ignore
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"未安装 PaddleOCR,请执行: pip install paddleocr paddlepaddle"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if self._engine is None:
|
||||||
|
self._engine = PaddleOCR(use_angle_cls=True, lang=self.lang, show_log=False)
|
||||||
|
|
||||||
|
result = self._engine.ocr(str(image_path), cls=True)
|
||||||
|
lines: list[str] = []
|
||||||
|
if result and result[0]:
|
||||||
|
for line in result[0]:
|
||||||
|
if line and len(line) >= 2:
|
||||||
|
text_part = line[1]
|
||||||
|
if isinstance(text_part, (list, tuple)) and text_part:
|
||||||
|
lines.append(str(text_part[0]))
|
||||||
|
elif isinstance(text_part, str):
|
||||||
|
lines.append(text_part)
|
||||||
|
return "\n".join(lines).strip()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Tesseract 本地 OCR 实现。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .base import OCRProvider
|
||||||
|
|
||||||
|
|
||||||
|
class TesseractOCR(OCRProvider):
|
||||||
|
"""通过 pytesseract 调用本地 tesseract。
|
||||||
|
|
||||||
|
需提前安装 tesseract-ocr 及中文语言包。
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "tesseract"
|
||||||
|
|
||||||
|
def __init__(self, lang: str = "chi_sim+eng", cmd: Optional[str] = None) -> None:
|
||||||
|
self.lang = lang
|
||||||
|
self.cmd = cmd
|
||||||
|
|
||||||
|
async def recognize(self, image_path: Path) -> str:
|
||||||
|
"""异步包装:避免阻塞事件循环。"""
|
||||||
|
return await asyncio.to_thread(self._sync_recognize, image_path)
|
||||||
|
|
||||||
|
def _sync_recognize(self, image_path: Path) -> str:
|
||||||
|
try:
|
||||||
|
import pytesseract
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("未安装 pytesseract / Pillow") from exc
|
||||||
|
|
||||||
|
if self.cmd:
|
||||||
|
pytesseract.pytesseract.tesseract_cmd = self.cmd
|
||||||
|
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
text = pytesseract.image_to_string(img, lang=self.lang)
|
||||||
|
return text.strip()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""视觉大模型 OCR:用多模态 API 从截图中提取文字。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .base import OCRProvider
|
||||||
|
from .openai_vision_client import chat_completions, safe_parse_json
|
||||||
|
|
||||||
|
|
||||||
|
_VISION_OCR_SYSTEM = """你是 OCR 助手。用户会给你一张截图,请尽可能完整地提取其中的文字。
|
||||||
|
只输出 JSON,格式:{"text": "提取到的全部文字,保留换行"}
|
||||||
|
如果没有可识别文字,text 填空字符串。"""
|
||||||
|
|
||||||
|
|
||||||
|
class VisionOCR(OCRProvider):
|
||||||
|
"""OpenAI 兼容视觉模型识文(GLM-4V / GPT-4o / Qwen-VL / Ollama 等)。"""
|
||||||
|
|
||||||
|
name = "vision"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
timeout: float = 60.0,
|
||||||
|
allow_upload: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.timeout = timeout
|
||||||
|
self.allow_upload = allow_upload
|
||||||
|
|
||||||
|
async def recognize(self, image_path: Path) -> str:
|
||||||
|
"""调用视觉模型提取文字。"""
|
||||||
|
if not self.allow_upload:
|
||||||
|
raise RuntimeError("敏感目录禁止上传图片,无法使用视觉 OCR")
|
||||||
|
|
||||||
|
content = await chat_completions(
|
||||||
|
base_url=self.base_url,
|
||||||
|
api_key=self.api_key,
|
||||||
|
model=self.model,
|
||||||
|
system_prompt=_VISION_OCR_SYSTEM,
|
||||||
|
user_text="请提取这张截图中的所有文字。",
|
||||||
|
image_path=image_path,
|
||||||
|
allow_upload=True,
|
||||||
|
timeout=self.timeout,
|
||||||
|
json_mode=True,
|
||||||
|
)
|
||||||
|
parsed = safe_parse_json(content)
|
||||||
|
text = parsed.get("text") or parsed.get("ocr_text") or content
|
||||||
|
return str(text).strip()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""OpenAI 兼容视觉 API 的公共封装:图片编码 + chat/completions 调用。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def image_to_data_url(image_path: Path, max_side: int | None = None) -> str:
|
||||||
|
"""将图片压缩并编码为 data URL。"""
|
||||||
|
max_side = max_side or settings.vlm_max_side
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
img = img.convert("RGB")
|
||||||
|
w, h = img.size
|
||||||
|
scale = max(w, h) / max_side
|
||||||
|
if scale > 1:
|
||||||
|
img = img.resize((int(w / scale), int(h / scale)), Image.LANCZOS)
|
||||||
|
buf = BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=82)
|
||||||
|
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
return f"data:image/jpeg;base64,{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_parse_json(content: str) -> dict[str, Any]:
|
||||||
|
"""解析模型 JSON 输出,兼容 markdown 包裹。"""
|
||||||
|
text = content.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.strip("`")
|
||||||
|
if text.lower().startswith("json"):
|
||||||
|
text = text[4:].strip()
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(text[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {"text": content}
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_completions(
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
system_prompt: str,
|
||||||
|
user_text: str,
|
||||||
|
image_path: Path | None = None,
|
||||||
|
allow_upload: bool = True,
|
||||||
|
timeout: float = 60.0,
|
||||||
|
json_mode: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""调用 /v1/chat/completions,返回 message.content 字符串。"""
|
||||||
|
user_content: list[dict[str, Any]] = [{"type": "text", "text": user_text}]
|
||||||
|
if image_path is not None and allow_upload:
|
||||||
|
data_url = image_to_data_url(image_path)
|
||||||
|
user_content.append({"type": "image_url", "image_url": {"url": data_url}})
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
],
|
||||||
|
"temperature": 0.2,
|
||||||
|
}
|
||||||
|
if json_mode:
|
||||||
|
payload["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
url = f"{base_url.rstrip('/')}/chat/completions"
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(url, json=payload, headers=headers)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("视觉 API 请求失败,尝试移除 response_format:%s", exc)
|
||||||
|
payload.pop("response_format", None)
|
||||||
|
resp = await client.post(url, json=payload, headers=headers)
|
||||||
|
|
||||||
|
if resp.status_code == 400 and "response_format" in resp.text:
|
||||||
|
payload.pop("response_format", None)
|
||||||
|
resp = await client.post(url, json=payload, headers=headers)
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError) as exc:
|
||||||
|
raise RuntimeError(f"视觉 API 返回结构异常: {data}") from exc
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""OpenAI 兼容 VLM 实现:覆盖 Ollama / GLM / MiniMax / Moonshot / OpenRouter / OpenAI。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
from .base import VLMProvider, VLMResult
|
||||||
|
from .openai_vision_client import chat_completions, safe_parse_json
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = """你是一个截图整理助手。用户会给你一张截图(可能附带 OCR 文本)。
|
||||||
|
请用简洁的中文,按以下 JSON 结构返回分析结果,**只输出 JSON,不要解释**:
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "一句话标题,不超过 24 个字",
|
||||||
|
"summary": "2-3 句话总结这张截图的内容、要点或笑点",
|
||||||
|
"category": "从给定分类列表中选一个最贴切的名字;如果都不符合就填'其他'",
|
||||||
|
"tags": ["3-6 个能帮助检索的细分标签"],
|
||||||
|
"todos": [
|
||||||
|
{"title": "如果截图里出现'待看/待读/待办/想试试/记一下'的内容,抽成一条 todo", "kind": "待读|待看|待办|学习", "note": "可空"}
|
||||||
|
],
|
||||||
|
"suggestion": "可选:给用户的进一步行动建议或同类资源提示,可空"
|
||||||
|
}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 标题要可读,不要复述"这是一张..."。
|
||||||
|
- summary 不要超过 80 字。
|
||||||
|
- todos 没有可识别项时给空数组。"""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICompatVLM(VLMProvider):
|
||||||
|
"""统一调用 /v1/chat/completions,图片以 base64 data URL 传入。"""
|
||||||
|
|
||||||
|
name = "openai_compat"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
timeout: float = 60.0,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
async def analyze(
|
||||||
|
self,
|
||||||
|
image_path: Path,
|
||||||
|
ocr_text: str,
|
||||||
|
categories: list[str],
|
||||||
|
allow_upload: bool,
|
||||||
|
) -> VLMResult:
|
||||||
|
"""调用模型并解析结构化 JSON。"""
|
||||||
|
prompt = (
|
||||||
|
f"可选分类:{', '.join(categories)}\n\n"
|
||||||
|
f"OCR 文本(可能不完整或为空):\n{ocr_text or '(无)'}"
|
||||||
|
)
|
||||||
|
content = await chat_completions(
|
||||||
|
base_url=self.base_url,
|
||||||
|
api_key=self.api_key,
|
||||||
|
model=self.model,
|
||||||
|
system_prompt=_SYSTEM_PROMPT,
|
||||||
|
user_text=prompt,
|
||||||
|
image_path=image_path if allow_upload else None,
|
||||||
|
allow_upload=allow_upload,
|
||||||
|
timeout=self.timeout,
|
||||||
|
json_mode=True,
|
||||||
|
)
|
||||||
|
parsed = safe_parse_json(content)
|
||||||
|
return _to_vlm_result(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_vlm_result(data: dict[str, Any]) -> VLMResult:
|
||||||
|
"""JSON -> dataclass,容错地兜住字段。"""
|
||||||
|
todos_raw = data.get("todos") or []
|
||||||
|
todos: list[dict[str, str]] = []
|
||||||
|
if isinstance(todos_raw, list):
|
||||||
|
for item in todos_raw:
|
||||||
|
if isinstance(item, dict) and item.get("title"):
|
||||||
|
todos.append(
|
||||||
|
{
|
||||||
|
"title": str(item.get("title", ""))[:512],
|
||||||
|
"kind": str(item.get("kind", "")) or "待办",
|
||||||
|
"note": str(item.get("note", "") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(item, str):
|
||||||
|
todos.append({"title": item, "kind": "待办", "note": ""})
|
||||||
|
tags_raw = data.get("tags") or []
|
||||||
|
if not isinstance(tags_raw, list):
|
||||||
|
tags_raw = []
|
||||||
|
return VLMResult(
|
||||||
|
title=str(data.get("title", "") or "")[:128],
|
||||||
|
summary=str(data.get("summary", "") or ""),
|
||||||
|
category=str(data.get("category") or "") or None,
|
||||||
|
tags=[str(t) for t in tags_raw if t][:8],
|
||||||
|
todos=todos,
|
||||||
|
suggestion=str(data.get("suggestion", "") or ""),
|
||||||
|
raw=data,
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""通用 Schema:状态、统计、设置。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class StatsResp(BaseModel):
|
||||||
|
total: int
|
||||||
|
pending_ocr: int
|
||||||
|
pending_ai: int
|
||||||
|
failed: int
|
||||||
|
by_category: list[dict[str, Any]]
|
||||||
|
by_date: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class WatchFolderIn(BaseModel):
|
||||||
|
path: str
|
||||||
|
enabled: bool = True
|
||||||
|
recursive: bool = True
|
||||||
|
is_sensitive: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WatchFolderOut(WatchFolderIn):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
color: Optional[str] = None
|
||||||
|
prompt_hint: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderConfig(BaseModel):
|
||||||
|
"""OCR/VLM Provider 配置。
|
||||||
|
|
||||||
|
type: openai_compat / tesseract / anthropic / none
|
||||||
|
base_url、api_key、model 等都是可选的,按 provider 类型决定。
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
extra: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderConfigOut(ProviderConfig):
|
||||||
|
"""读取用:api_key 永远为空,只通过 api_key_mask 暴露提示。"""
|
||||||
|
|
||||||
|
api_key_mask: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecognitionModeIn(BaseModel):
|
||||||
|
"""文字识别策略:传统 OCR / 视觉 AI / 混合。"""
|
||||||
|
|
||||||
|
mode: str # ocr | vision | hybrid
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderTestResult(BaseModel):
|
||||||
|
"""Provider 连通性测试结果。"""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
detail: Optional[str] = None
|
||||||
|
latency_ms: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TodoUpdate(BaseModel):
|
||||||
|
status: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""分析任务队列的请求/响应模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class JobOut(BaseModel):
|
||||||
|
|
||||||
|
"""单条任务详情,含关联截图摘要。"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
id: int
|
||||||
|
|
||||||
|
screenshot_id: int
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
|
||||||
|
status: str
|
||||||
|
|
||||||
|
retries: int
|
||||||
|
|
||||||
|
last_error: Optional[str] = None
|
||||||
|
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
finished_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
thumb_url: Optional[str] = None
|
||||||
|
|
||||||
|
path: Optional[str] = None
|
||||||
|
|
||||||
|
ai_title: Optional[str] = None
|
||||||
|
|
||||||
|
ai_status: Optional[str] = None
|
||||||
|
|
||||||
|
ocr_status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class JobListResp(BaseModel):
|
||||||
|
|
||||||
|
items: list[JobOut]
|
||||||
|
|
||||||
|
total: int
|
||||||
|
|
||||||
|
page: int
|
||||||
|
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class JobRetryIn(BaseModel):
|
||||||
|
|
||||||
|
"""可选:仅重试指定 job id;不传则重试全部 failed。"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
job_ids: Optional[list[int]] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""截图相关的请求/响应模型。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TagOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
color: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
color: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotBrief(BaseModel):
|
||||||
|
"""卡片列表用:尽量精简。"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
path: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
captured_at: datetime
|
||||||
|
thumb_url: Optional[str] = None
|
||||||
|
ai_title: Optional[str] = None
|
||||||
|
ai_status: str
|
||||||
|
ocr_status: str
|
||||||
|
is_favorite: bool = False
|
||||||
|
category: Optional[CategoryOut] = None
|
||||||
|
tags: list[TagOut] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TodoBrief(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
note: Optional[str] = None
|
||||||
|
kind: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
screenshot_id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TodoListResp(BaseModel):
|
||||||
|
items: list[TodoBrief]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotDetail(ScreenshotBrief):
|
||||||
|
"""详情页用:含 OCR 与 AI 文本。"""
|
||||||
|
|
||||||
|
file_url: str
|
||||||
|
size: int
|
||||||
|
ocr_text: Optional[str] = None
|
||||||
|
ai_summary: Optional[str] = None
|
||||||
|
ai_suggestion: Optional[str] = None
|
||||||
|
todos: list[TodoBrief] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotListResp(BaseModel):
|
||||||
|
items: list[ScreenshotBrief]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotUpdate(BaseModel):
|
||||||
|
"""前端更新可写字段。"""
|
||||||
|
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
is_favorite: Optional[bool] = None
|
||||||
|
is_hidden: Optional[bool] = None
|
||||||
|
tags: Optional[list[str]] = Field(default=None, description="标签名列表,自动新建")
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""单张截图的分析逻辑:OCR -> VLM -> 写回数据库。
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
- 不在长时间网络调用期间持有 SQLite 写事务,避免 `database is locked`。
|
||||||
|
- 把流程拆为「短事务(取配置/标记状态)」 -> 「无事务(OCR/VLM 网络调用)」
|
||||||
|
-> 「短事务(写回结果)」。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import session_scope
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.core.path_utils import is_accessible_file, path_from_storage, path_is_under
|
||||||
|
from app.models.category import Category, DEFAULT_CATEGORIES
|
||||||
|
from app.models.meta import ScreenshotMeta
|
||||||
|
from app.models.screenshot import ProcessStatus, Screenshot
|
||||||
|
from app.models.setting import (
|
||||||
|
DEFAULT_RECOGNITION_MODE,
|
||||||
|
KEY_OCR_PROVIDER,
|
||||||
|
KEY_RECOGNITION_MODE,
|
||||||
|
KEY_VLM_PROVIDER,
|
||||||
|
)
|
||||||
|
from app.models.tag import Tag
|
||||||
|
from app.models.todo import Todo, TodoStatus
|
||||||
|
from app.models.watch_folder import WatchFolder
|
||||||
|
from app.providers import (
|
||||||
|
RECOGNITION_MODES,
|
||||||
|
build_ocr_provider,
|
||||||
|
build_vision_ocr,
|
||||||
|
build_vlm_provider,
|
||||||
|
)
|
||||||
|
from app.providers.base import VLMResult
|
||||||
|
from app.schemas.common import ProviderConfig
|
||||||
|
from app.services.exif_utils import is_exif_location_tag
|
||||||
|
from app.services.settings_store import get_provider_config, get_setting
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _PreparedContext:
|
||||||
|
"""从短事务中导出的、不依赖 ORM 会话的纯数据。"""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
ocr_cfg: Optional[ProviderConfig]
|
||||||
|
vlm_cfg: Optional[ProviderConfig]
|
||||||
|
recognition_mode: str
|
||||||
|
category_names: list[str]
|
||||||
|
allow_upload: bool
|
||||||
|
exists: bool
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_screenshot_by_id(screenshot_id: int) -> None:
|
||||||
|
"""对外入口:按 id 分析单张截图。
|
||||||
|
|
||||||
|
被 worker 调度。函数内部自己管理多个短事务。
|
||||||
|
"""
|
||||||
|
ctx = _prepare(screenshot_id)
|
||||||
|
if ctx is None:
|
||||||
|
return # 截图已被删除
|
||||||
|
if not ctx.exists:
|
||||||
|
_persist_missing(screenshot_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
ocr_provider = _safe_build(
|
||||||
|
lambda c: build_ocr_provider(c, allow_upload=ctx.allow_upload),
|
||||||
|
ctx.ocr_cfg if _use_traditional_ocr(ctx) else None,
|
||||||
|
"OCR",
|
||||||
|
)
|
||||||
|
vlm_provider = _safe_build(build_vlm_provider, ctx.vlm_cfg, "VLM")
|
||||||
|
|
||||||
|
# ---- 文字识别阶段(在事务外执行)----
|
||||||
|
ocr_text, ocr_status = await _extract_text(screenshot_id, ctx, ocr_provider)
|
||||||
|
|
||||||
|
# ---- VLM 阶段(事务外)----
|
||||||
|
vlm_result: Optional[VLMResult] = None
|
||||||
|
ai_status = ProcessStatus.SKIPPED.value
|
||||||
|
vlm_error: Optional[Exception] = None
|
||||||
|
if vlm_provider is not None:
|
||||||
|
_mark_status(screenshot_id, ai=ProcessStatus.RUNNING.value)
|
||||||
|
try:
|
||||||
|
vlm_result = await vlm_provider.analyze(
|
||||||
|
image_path=ctx.path,
|
||||||
|
ocr_text=ocr_text,
|
||||||
|
categories=ctx.category_names,
|
||||||
|
allow_upload=ctx.allow_upload,
|
||||||
|
)
|
||||||
|
ai_status = ProcessStatus.DONE.value
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("VLM 失败 #%d: %s", screenshot_id, exc)
|
||||||
|
ai_status = ProcessStatus.FAILED.value
|
||||||
|
vlm_error = exc
|
||||||
|
|
||||||
|
# ---- 写回阶段(短事务)----
|
||||||
|
_persist_result(
|
||||||
|
screenshot_id=screenshot_id,
|
||||||
|
ocr_text=ocr_text,
|
||||||
|
ocr_status=ocr_status,
|
||||||
|
ai_status=ai_status,
|
||||||
|
vlm_result=vlm_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
if vlm_error is not None:
|
||||||
|
raise vlm_error # 让 worker 决定重试
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_ocr_only_by_id(screenshot_id: int) -> None:
|
||||||
|
"""仅补跑 OCR/视觉识文,不改动 AI 分析结果。
|
||||||
|
|
||||||
|
用于 ai_status=done 但 ocr_status=failed 的截图。
|
||||||
|
OCR 仍失败时抛异常,由 worker 按 max_retries 重试。
|
||||||
|
"""
|
||||||
|
ctx = _prepare(screenshot_id)
|
||||||
|
if ctx is None:
|
||||||
|
return
|
||||||
|
if not ctx.exists:
|
||||||
|
_persist_missing(screenshot_id)
|
||||||
|
raise RuntimeError("截图文件丢失")
|
||||||
|
|
||||||
|
ocr_provider = _safe_build(
|
||||||
|
lambda c: build_ocr_provider(c, allow_upload=ctx.allow_upload),
|
||||||
|
ctx.ocr_cfg if _use_traditional_ocr(ctx) else None,
|
||||||
|
"OCR",
|
||||||
|
)
|
||||||
|
|
||||||
|
ocr_text, ocr_status = await _extract_text(screenshot_id, ctx, ocr_provider)
|
||||||
|
_persist_ocr_only(screenshot_id, ocr_text, ocr_status)
|
||||||
|
|
||||||
|
if ocr_status == ProcessStatus.FAILED.value:
|
||||||
|
raise RuntimeError("OCR 识别失败")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 短事务工具 ---------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare(screenshot_id: int) -> Optional[_PreparedContext]:
|
||||||
|
"""短事务:读取 Provider 配置、分类列表、敏感目录判定。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
return None
|
||||||
|
image_path = path_from_storage(shot.path)
|
||||||
|
exists = is_accessible_file(image_path)
|
||||||
|
|
||||||
|
ocr_cfg = _load_provider_config(session, KEY_OCR_PROVIDER)
|
||||||
|
vlm_cfg = _load_provider_config(session, KEY_VLM_PROVIDER)
|
||||||
|
mode = get_setting(session, KEY_RECOGNITION_MODE, DEFAULT_RECOGNITION_MODE)
|
||||||
|
if mode not in RECOGNITION_MODES:
|
||||||
|
mode = DEFAULT_RECOGNITION_MODE
|
||||||
|
|
||||||
|
categories = _ensure_default_categories(session)
|
||||||
|
category_names = [c.name for c in categories]
|
||||||
|
|
||||||
|
allow_upload = not _is_sensitive(session, image_path)
|
||||||
|
|
||||||
|
return _PreparedContext(
|
||||||
|
path=image_path,
|
||||||
|
ocr_cfg=ocr_cfg,
|
||||||
|
vlm_cfg=vlm_cfg,
|
||||||
|
recognition_mode=mode,
|
||||||
|
category_names=category_names,
|
||||||
|
allow_upload=allow_upload,
|
||||||
|
exists=exists,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _use_traditional_ocr(ctx: _PreparedContext) -> bool:
|
||||||
|
"""混合/传统模式下是否启用 OCR 区配置(排除 vision 类型,vision 单独处理)。"""
|
||||||
|
if ctx.recognition_mode not in ("ocr", "hybrid"):
|
||||||
|
return False
|
||||||
|
if ctx.ocr_cfg is None or ctx.ocr_cfg.type in ("", "none", "disabled", "vision"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_text(
|
||||||
|
screenshot_id: int,
|
||||||
|
ctx: _PreparedContext,
|
||||||
|
ocr_provider,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""按识别模式提取文字:传统 OCR / 视觉 AI / 混合。"""
|
||||||
|
ocr_text = ""
|
||||||
|
ocr_status = ProcessStatus.SKIPPED.value
|
||||||
|
mode = ctx.recognition_mode
|
||||||
|
|
||||||
|
# 1) 传统 OCR(Tesseract / Paddle / HTTP)
|
||||||
|
if ocr_provider is not None:
|
||||||
|
_mark_status(screenshot_id, ocr=ProcessStatus.RUNNING.value)
|
||||||
|
try:
|
||||||
|
ocr_text = await ocr_provider.recognize(ctx.path)
|
||||||
|
ocr_status = ProcessStatus.DONE.value
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("OCR 失败 #%d: %s", screenshot_id, exc)
|
||||||
|
ocr_status = ProcessStatus.FAILED.value
|
||||||
|
|
||||||
|
# 2) 视觉 AI 识文
|
||||||
|
need_vision = mode == "vision" or (
|
||||||
|
mode == "hybrid" and not ocr_text.strip()
|
||||||
|
)
|
||||||
|
if mode == "ocr" and ctx.ocr_cfg and ctx.ocr_cfg.type == "vision":
|
||||||
|
# 用户在 OCR 区选了「视觉模型识文」
|
||||||
|
need_vision = True
|
||||||
|
|
||||||
|
if need_vision:
|
||||||
|
vision_cfg = _pick_vision_config(ctx)
|
||||||
|
vision = _safe_build(
|
||||||
|
lambda c: build_vision_ocr(c, allow_upload=ctx.allow_upload),
|
||||||
|
vision_cfg,
|
||||||
|
"VisionOCR",
|
||||||
|
)
|
||||||
|
if vision is not None:
|
||||||
|
_mark_status(screenshot_id, ocr=ProcessStatus.RUNNING.value)
|
||||||
|
try:
|
||||||
|
ocr_text = await vision.recognize(ctx.path)
|
||||||
|
ocr_status = ProcessStatus.DONE.value
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("视觉识文失败 #%d: %s", screenshot_id, exc)
|
||||||
|
if ocr_status != ProcessStatus.DONE.value:
|
||||||
|
ocr_status = ProcessStatus.FAILED.value
|
||||||
|
|
||||||
|
return ocr_text, ocr_status
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_vision_config(ctx: _PreparedContext) -> Optional[ProviderConfig]:
|
||||||
|
"""决定视觉识文用哪套配置:优先 OCR 区的 vision,否则 VLM 区。"""
|
||||||
|
if ctx.ocr_cfg and ctx.ocr_cfg.type == "vision":
|
||||||
|
return ctx.ocr_cfg
|
||||||
|
if ctx.recognition_mode == "vision" or ctx.recognition_mode == "hybrid":
|
||||||
|
return ctx.vlm_cfg
|
||||||
|
return ctx.vlm_cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_status(
|
||||||
|
screenshot_id: int,
|
||||||
|
ocr: Optional[str] = None,
|
||||||
|
ai: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""短事务:把截图标记为 running,方便前端看到进度。"""
|
||||||
|
if ocr is None and ai is None:
|
||||||
|
return
|
||||||
|
with session_scope() as session:
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
return
|
||||||
|
if ocr is not None:
|
||||||
|
shot.ocr_status = ocr
|
||||||
|
if ai is not None:
|
||||||
|
shot.ai_status = ai
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_missing(screenshot_id: int) -> None:
|
||||||
|
"""短事务:标记文件已丢失。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
return
|
||||||
|
shot.ocr_status = ProcessStatus.FAILED.value
|
||||||
|
shot.ai_status = ProcessStatus.FAILED.value
|
||||||
|
meta = _get_or_create_meta(session, screenshot_id)
|
||||||
|
meta.ai_summary = "(文件丢失)"
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_result(
|
||||||
|
screenshot_id: int,
|
||||||
|
ocr_text: str,
|
||||||
|
ocr_status: str,
|
||||||
|
ai_status: str,
|
||||||
|
vlm_result: Optional[VLMResult],
|
||||||
|
) -> None:
|
||||||
|
"""短事务:把 OCR/VLM 结果写回 DB,包括 meta/tags/category/todos。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
return
|
||||||
|
shot.ocr_status = ocr_status
|
||||||
|
shot.ai_status = ai_status
|
||||||
|
|
||||||
|
meta = _get_or_create_meta(session, screenshot_id)
|
||||||
|
meta.ocr_text = ocr_text or None
|
||||||
|
|
||||||
|
if vlm_result is not None:
|
||||||
|
meta.ai_title = vlm_result.title or None
|
||||||
|
meta.ai_summary = vlm_result.summary or None
|
||||||
|
meta.ai_suggestion = vlm_result.suggestion or None
|
||||||
|
meta.ai_raw_json = json.dumps(vlm_result.raw, ensure_ascii=False)
|
||||||
|
|
||||||
|
categories = list(session.scalars(select(Category)).all())
|
||||||
|
category = _resolve_category(session, vlm_result.category, categories)
|
||||||
|
if category is not None:
|
||||||
|
shot.category_id = category.id
|
||||||
|
|
||||||
|
_sync_tags(session, shot, vlm_result.tags)
|
||||||
|
_sync_todos(session, shot, vlm_result.todos)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_ocr_only(screenshot_id: int, ocr_text: str, ocr_status: str) -> None:
|
||||||
|
"""短事务:仅写回 OCR 文本与状态,保留已有 AI 字段。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
shot = session.get(Screenshot, screenshot_id)
|
||||||
|
if shot is None:
|
||||||
|
return
|
||||||
|
shot.ocr_status = ocr_status
|
||||||
|
meta = _get_or_create_meta(session, screenshot_id)
|
||||||
|
meta.ocr_text = ocr_text or None
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_ocr_jobs(*, limit: int = 500) -> int:
|
||||||
|
"""为「AI 已成功、OCR 失败」的截图批量创建 OCR 补跑任务。
|
||||||
|
|
||||||
|
跳过已有 pending/running 的 ocr 任务,避免重复入队。
|
||||||
|
"""
|
||||||
|
from app.models.job import Job, JobKind, JobStatus
|
||||||
|
|
||||||
|
active_status = (JobStatus.PENDING.value, JobStatus.RUNNING.value)
|
||||||
|
created = 0
|
||||||
|
with session_scope() as session:
|
||||||
|
# 已有活跃 OCR 任务的 screenshot_id
|
||||||
|
busy_ids = set(
|
||||||
|
session.scalars(
|
||||||
|
select(Job.screenshot_id).where(
|
||||||
|
Job.kind == JobKind.OCR.value,
|
||||||
|
Job.status.in_(active_status),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
shots = session.scalars(
|
||||||
|
select(Screenshot)
|
||||||
|
.where(
|
||||||
|
Screenshot.ocr_status == ProcessStatus.FAILED.value,
|
||||||
|
Screenshot.ai_status == ProcessStatus.DONE.value,
|
||||||
|
)
|
||||||
|
.order_by(Screenshot.id.asc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
for shot in shots:
|
||||||
|
if shot.id in busy_ids:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
Job(
|
||||||
|
screenshot_id=shot.id,
|
||||||
|
kind=JobKind.OCR.value,
|
||||||
|
status=JobStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
busy_ids.add(shot.id)
|
||||||
|
created += 1
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 内部辅助 ---------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _load_provider_config(session, key: str) -> Optional[ProviderConfig]:
|
||||||
|
raw = get_provider_config(session, key)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return ProviderConfig(**raw)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Provider 配置 %s 解析失败: %s", key, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_build(builder, cfg: Optional[ProviderConfig], label: str):
|
||||||
|
if cfg is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return builder(cfg)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("%s Provider 构造失败: %s", label, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sensitive(session, image_path: Path) -> bool:
|
||||||
|
"""判断文件是否落在某个标记为敏感的监听目录内。"""
|
||||||
|
sensitive_dirs = session.scalars(
|
||||||
|
select(WatchFolder.path).where(WatchFolder.is_sensitive.is_(True))
|
||||||
|
).all()
|
||||||
|
child = str(image_path)
|
||||||
|
for d in sensitive_dirs:
|
||||||
|
if path_is_under(d, child):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_meta(session, screenshot_id: int) -> ScreenshotMeta:
|
||||||
|
meta = session.get(ScreenshotMeta, screenshot_id)
|
||||||
|
if meta is None:
|
||||||
|
meta = ScreenshotMeta(screenshot_id=screenshot_id)
|
||||||
|
session.add(meta)
|
||||||
|
session.flush()
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_categories() -> None:
|
||||||
|
"""对外暴露:启动时 seed 默认分类。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
_ensure_default_categories(session)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_default_categories(session) -> list[Category]:
|
||||||
|
"""首次运行时灌入默认分类,返回最新列表。"""
|
||||||
|
existing = session.scalars(select(Category)).all()
|
||||||
|
if existing:
|
||||||
|
return list(existing)
|
||||||
|
for item in DEFAULT_CATEGORIES:
|
||||||
|
session.add(Category(**item))
|
||||||
|
session.flush()
|
||||||
|
return list(session.scalars(select(Category)).all())
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_category(
|
||||||
|
session,
|
||||||
|
name: str | None,
|
||||||
|
categories: list[Category],
|
||||||
|
) -> Optional[Category]:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
normalized = name.strip()
|
||||||
|
for c in categories:
|
||||||
|
if c.name == normalized or c.name in normalized or normalized in c.name:
|
||||||
|
return c
|
||||||
|
new_cat = Category(name=normalized[:64], color=None, prompt_hint=None)
|
||||||
|
session.add(new_cat)
|
||||||
|
session.flush()
|
||||||
|
categories.append(new_cat)
|
||||||
|
return new_cat
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_tags(session, screenshot: Screenshot, tag_names: list[str]) -> None:
|
||||||
|
"""根据 AI 给的标签名同步多对多关系;保留 EXIF 地点标签不被覆盖。"""
|
||||||
|
exif_tags = [t for t in (screenshot.tags or []) if is_exif_location_tag(t.name)]
|
||||||
|
exif_names = {t.name for t in exif_tags}
|
||||||
|
|
||||||
|
seen: set[str] = set(exif_names)
|
||||||
|
tag_objs: list[Tag] = list(exif_tags)
|
||||||
|
for raw_name in tag_names:
|
||||||
|
name = (raw_name or "").strip()[:64]
|
||||||
|
if not name or name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
tag = session.scalar(select(Tag).where(Tag.name == name))
|
||||||
|
if tag is None:
|
||||||
|
tag = Tag(name=name)
|
||||||
|
session.add(tag)
|
||||||
|
session.flush()
|
||||||
|
tag_objs.append(tag)
|
||||||
|
screenshot.tags = tag_objs
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_todos(
|
||||||
|
session,
|
||||||
|
screenshot: Screenshot,
|
||||||
|
todos: list[dict[str, str]],
|
||||||
|
) -> None:
|
||||||
|
"""以 AI 输出覆盖该截图未完成的 todos;保留用户已完成/搁置项。"""
|
||||||
|
existing = list(screenshot.todos)
|
||||||
|
for t in existing:
|
||||||
|
if t.status in (TodoStatus.DONE.value, TodoStatus.DROPPED.value):
|
||||||
|
continue
|
||||||
|
session.delete(t)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
for item in todos:
|
||||||
|
title = (item.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
Todo(
|
||||||
|
screenshot_id=screenshot.id,
|
||||||
|
title=title[:512],
|
||||||
|
note=(item.get("note") or "")[:2000] or None,
|
||||||
|
kind=(item.get("kind") or "待办")[:32],
|
||||||
|
status=TodoStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""从图片 EXIF 提取拍摄时间与 GPS 地点标签。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from fractions import Fraction
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PIL import ExifTags, Image
|
||||||
|
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# EXIF 地点类标签前缀,重分析时保留不被 AI 覆盖
|
||||||
|
EXIF_TAG_PREFIX = "地点:"
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio_to_float(value) -> float:
|
||||||
|
"""EXIF 有理数 → float。"""
|
||||||
|
if isinstance(value, tuple) and len(value) == 2:
|
||||||
|
num, den = value
|
||||||
|
return float(num) / float(den) if den else 0.0
|
||||||
|
if isinstance(value, Fraction):
|
||||||
|
return float(value)
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _dms_to_decimal(dms: tuple, ref: str) -> Optional[float]:
|
||||||
|
"""度分秒 → 十进制度。"""
|
||||||
|
try:
|
||||||
|
deg, minutes, seconds = dms
|
||||||
|
decimal = _ratio_to_float(deg) + _ratio_to_float(minutes) / 60 + _ratio_to_float(seconds) / 3600
|
||||||
|
if ref in ("S", "W"):
|
||||||
|
decimal = -decimal
|
||||||
|
return round(decimal, 6)
|
||||||
|
except (TypeError, ValueError, ZeroDivisionError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_image_metadata(path: Path) -> tuple[Optional[datetime], list[str]]:
|
||||||
|
"""读取 EXIF,返回 (拍摄时间, 地点标签列表)。"""
|
||||||
|
captured: Optional[datetime] = None
|
||||||
|
location_tags: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(path) as img:
|
||||||
|
exif = img.getexif()
|
||||||
|
if not exif:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
# 拍摄时间:优先 DateTimeOriginal
|
||||||
|
for key in (36867, 36868, 306): # DateTimeOriginal / DateTimeDigitized / DateTime
|
||||||
|
raw = exif.get(key)
|
||||||
|
if raw:
|
||||||
|
captured = _parse_exif_datetime(str(raw))
|
||||||
|
if captured:
|
||||||
|
break
|
||||||
|
|
||||||
|
# GPS → 地点标签
|
||||||
|
gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) if hasattr(exif, "get_ifd") else None
|
||||||
|
if gps_ifd:
|
||||||
|
lat = _dms_to_decimal(
|
||||||
|
gps_ifd.get(2),
|
||||||
|
gps_ifd.get(1, "N"),
|
||||||
|
)
|
||||||
|
lon = _dms_to_decimal(
|
||||||
|
gps_ifd.get(4),
|
||||||
|
gps_ifd.get(3, "E"),
|
||||||
|
)
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
location_tags.append(f"{EXIF_TAG_PREFIX}{lat},{lon}")
|
||||||
|
|
||||||
|
# 部分设备写入可读地名(XP Keywords / ImageDescription 等)
|
||||||
|
for key, val in exif.items():
|
||||||
|
tag_name = ExifTags.TAGS.get(key, "")
|
||||||
|
if tag_name in ("ImageDescription", "XPComment") and val:
|
||||||
|
text = str(val).strip()[:64]
|
||||||
|
if text and _looks_like_place(text):
|
||||||
|
location_tags.append(f"{EXIF_TAG_PREFIX}{text}")
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.debug("读取 EXIF 失败 %s: %s", path.name, exc)
|
||||||
|
|
||||||
|
return captured, location_tags
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exif_datetime(raw: str) -> Optional[datetime]:
|
||||||
|
"""解析 EXIF 时间字符串。"""
|
||||||
|
for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(raw.strip(), fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_place(text: str) -> bool:
|
||||||
|
"""粗判字符串是否像地名(含中文或常见地址关键词)。"""
|
||||||
|
keywords = ("市", "省", "区", "县", "镇", "村", "路", "街", "国", "GPS")
|
||||||
|
return any(k in text for k in keywords) or any("\u4e00" <= c <= "\u9fff" for c in text)
|
||||||
|
|
||||||
|
|
||||||
|
def is_exif_location_tag(name: str) -> bool:
|
||||||
|
"""是否为 EXIF 自动写入的地点标签。"""
|
||||||
|
return name.startswith(EXIF_TAG_PREFIX)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""将磁盘上的截图文件入库 + 排队分析。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.path_utils import (
|
||||||
|
is_accessible_dir,
|
||||||
|
is_accessible_file,
|
||||||
|
path_from_storage,
|
||||||
|
path_to_storage,
|
||||||
|
)
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.models.job import Job, JobKind, JobStatus
|
||||||
|
from app.models.screenshot import ProcessStatus, Screenshot
|
||||||
|
from app.models.tag import Tag
|
||||||
|
from app.services.exif_utils import extract_image_metadata
|
||||||
|
from app.services.thumbnail import file_hash, generate_thumbnail, is_supported
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_path(session: Session, path: Path) -> Optional[Screenshot]:
|
||||||
|
"""单文件入库。返回 Screenshot 或 None(不支持/重复时)。"""
|
||||||
|
if not is_accessible_file(path) or not path.is_file():
|
||||||
|
return None
|
||||||
|
if not is_supported(path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
stored_path = path_to_storage(path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
digest = file_hash(path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("无法读取文件 %s: %s", path, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
existing = session.scalar(select(Screenshot).where(Screenshot.file_hash == digest))
|
||||||
|
if existing:
|
||||||
|
# 同一内容重命名/移动:更新路径
|
||||||
|
if existing.path != stored_path:
|
||||||
|
existing.path = stored_path
|
||||||
|
session.flush()
|
||||||
|
return existing
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(path) as img:
|
||||||
|
width, height = img.size
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("无法读取图片尺寸 %s: %s", path, exc)
|
||||||
|
width, height = 0, 0
|
||||||
|
|
||||||
|
stat = path.stat()
|
||||||
|
captured_at = datetime.fromtimestamp(stat.st_mtime)
|
||||||
|
exif_time, location_tags = extract_image_metadata(path)
|
||||||
|
if exif_time is not None:
|
||||||
|
captured_at = exif_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
thumb = generate_thumbnail(path)
|
||||||
|
thumb_path = thumb.as_posix()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("生成缩略图失败 %s: %s", path, exc)
|
||||||
|
thumb_path = None
|
||||||
|
|
||||||
|
shot = Screenshot(
|
||||||
|
path=stored_path,
|
||||||
|
file_hash=digest,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
size=stat.st_size,
|
||||||
|
captured_at=captured_at,
|
||||||
|
thumb_path=thumb_path,
|
||||||
|
ocr_status=ProcessStatus.PENDING.value,
|
||||||
|
ai_status=ProcessStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
session.add(shot)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
if location_tags:
|
||||||
|
_attach_location_tags(session, shot, location_tags)
|
||||||
|
|
||||||
|
job = Job(screenshot_id=shot.id, kind=JobKind.FULL.value, status=JobStatus.PENDING.value)
|
||||||
|
session.add(job)
|
||||||
|
logger.info("入库 #%d %s", shot.id, path.name)
|
||||||
|
return shot
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_location_tags(session: Session, shot: Screenshot, tag_names: list[str]) -> None:
|
||||||
|
"""入库时写入 EXIF 地点标签。"""
|
||||||
|
tag_objs: list[Tag] = []
|
||||||
|
for raw in tag_names:
|
||||||
|
name = (raw or "").strip()[:64]
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
tag = session.scalar(select(Tag).where(Tag.name == name))
|
||||||
|
if tag is None:
|
||||||
|
tag = Tag(name=name)
|
||||||
|
session.add(tag)
|
||||||
|
session.flush()
|
||||||
|
tag_objs.append(tag)
|
||||||
|
shot.tags = tag_objs
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_directory(
|
||||||
|
session: Session,
|
||||||
|
root: Path | str,
|
||||||
|
recursive: bool = True,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""遍历目录入库。返回 (新增数, 跳过数)。支持 UNC 网络路径。"""
|
||||||
|
root_p = path_from_storage(str(root)) if isinstance(root, str) else root
|
||||||
|
if not is_accessible_dir(root_p):
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
iterator: Iterable[Path]
|
||||||
|
if recursive:
|
||||||
|
iterator = (p for p in root_p.rglob("*") if p.is_file())
|
||||||
|
else:
|
||||||
|
iterator = (p for p in root_p.iterdir() if p.is_file())
|
||||||
|
|
||||||
|
added, skipped = 0, 0
|
||||||
|
for path in iterator:
|
||||||
|
if not is_supported(path):
|
||||||
|
continue
|
||||||
|
stored = path_to_storage(path)
|
||||||
|
before = session.scalar(
|
||||||
|
select(Screenshot.id).where(Screenshot.path == stored)
|
||||||
|
)
|
||||||
|
result = ingest_path(session, path)
|
||||||
|
if result is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if before is None:
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
# 批量提交,避免巨型事务
|
||||||
|
if (added + skipped) % 50 == 0:
|
||||||
|
session.commit()
|
||||||
|
session.commit()
|
||||||
|
return added, skipped
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Provider 连通性测试:OCR / 视觉 AI。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.providers import build_ocr_provider
|
||||||
|
from app.schemas.common import ProviderConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderTestError(Exception):
|
||||||
|
"""测试失败,携带用户可读信息。"""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provider_config(key: str, cfg: ProviderConfig) -> dict[str, Any]:
|
||||||
|
"""测试 OCR 或 VLM Provider 连通性,返回 {ok, message, detail, latency_ms}。"""
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
if cfg.type in ("", "none", "disabled"):
|
||||||
|
raise ProviderTestError("当前类型为「不使用」,无需测试")
|
||||||
|
|
||||||
|
if key == KEY_OCR:
|
||||||
|
message, detail = await _test_ocr(cfg)
|
||||||
|
elif key == KEY_VLM:
|
||||||
|
message, detail = await _test_vlm(cfg)
|
||||||
|
else:
|
||||||
|
raise ProviderTestError(f"未知配置键: {key}")
|
||||||
|
|
||||||
|
latency = int((time.perf_counter() - started) * 1000)
|
||||||
|
return {"ok": True, "message": message, "detail": detail, "latency_ms": latency}
|
||||||
|
except ProviderTestError as exc:
|
||||||
|
latency = int((time.perf_counter() - started) * 1000)
|
||||||
|
return {"ok": False, "message": str(exc), "detail": None, "latency_ms": latency}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
latency = int((time.perf_counter() - started) * 1000)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": f"测试失败: {exc}",
|
||||||
|
"detail": repr(exc),
|
||||||
|
"latency_ms": latency,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
KEY_OCR = "ocr_provider"
|
||||||
|
KEY_VLM = "vlm_provider"
|
||||||
|
|
||||||
|
# 1x1 白图,用于 HTTP OCR / 视觉测试
|
||||||
|
_TINY_PNG_B64 = (
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_ocr(cfg: ProviderConfig) -> tuple[str, str | None]:
|
||||||
|
if cfg.type == "tesseract":
|
||||||
|
return await _test_tesseract(cfg)
|
||||||
|
if cfg.type == "paddleocr":
|
||||||
|
return await _test_paddle(cfg)
|
||||||
|
if cfg.type == "http":
|
||||||
|
return await _test_http_ocr(cfg)
|
||||||
|
if cfg.type == "vision":
|
||||||
|
return await _test_openai_compat(cfg, label="视觉 OCR")
|
||||||
|
raise ProviderTestError(f"不支持的 OCR 类型: {cfg.type}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_vlm(cfg: ProviderConfig) -> tuple[str, str | None]:
|
||||||
|
if cfg.type in ("openai_compat", "openai", "ollama", "glm", "minimax", "moonshot", "vision"):
|
||||||
|
return await _test_openai_compat(cfg, label="视觉 AI")
|
||||||
|
raise ProviderTestError(f"不支持的 VLM 类型: {cfg.type}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_tesseract(cfg: ProviderConfig) -> tuple[str, str | None]:
|
||||||
|
provider = build_ocr_provider(cfg, allow_upload=True)
|
||||||
|
if provider is None:
|
||||||
|
raise ProviderTestError("无法构造 Tesseract Provider")
|
||||||
|
|
||||||
|
def _check() -> str:
|
||||||
|
import pytesseract
|
||||||
|
|
||||||
|
if cfg.extra.get("cmd"):
|
||||||
|
pytesseract.pytesseract.tesseract_cmd = cfg.extra["cmd"]
|
||||||
|
version = pytesseract.get_tesseract_version()
|
||||||
|
return str(version)
|
||||||
|
|
||||||
|
version = await asyncio.to_thread(_check)
|
||||||
|
return f"Tesseract 可用,版本 {version}", f"lang={cfg.extra.get('lang', 'chi_sim+eng')}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_paddle(cfg: ProviderConfig) -> tuple[str, str | None]:
|
||||||
|
def _check() -> str:
|
||||||
|
try:
|
||||||
|
import paddleocr # noqa: F401
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ProviderTestError(
|
||||||
|
"未安装 PaddleOCR,请执行: pip install paddleocr paddlepaddle"
|
||||||
|
) from exc
|
||||||
|
return "PaddleOCR 模块已安装"
|
||||||
|
|
||||||
|
detail = await asyncio.to_thread(_check)
|
||||||
|
provider = build_ocr_provider(cfg, allow_upload=True)
|
||||||
|
if provider is None:
|
||||||
|
raise ProviderTestError("无法构造 PaddleOCR Provider")
|
||||||
|
return "PaddleOCR 可用", detail
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_http_ocr(cfg: ProviderConfig) -> tuple[str, str | None]:
|
||||||
|
if not cfg.base_url:
|
||||||
|
raise ProviderTestError("请填写 OCR API URL")
|
||||||
|
provider = build_ocr_provider(cfg, allow_upload=True)
|
||||||
|
if provider is None:
|
||||||
|
raise ProviderTestError("无法构造 HTTP OCR Provider")
|
||||||
|
|
||||||
|
# 写入临时 tiny png 再调用
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
|
tmp.write(base64.b64decode(_TINY_PNG_B64))
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
try:
|
||||||
|
text = await provider.recognize(tmp_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
preview = (text or "").strip()[:80] or "(空响应,但接口可达)"
|
||||||
|
return "HTTP OCR 接口可达", f"响应预览: {preview}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_openai_compat(cfg: ProviderConfig, *, label: str) -> tuple[str, str | None]:
|
||||||
|
base_url = (cfg.base_url or "http://localhost:11434/v1").rstrip("/")
|
||||||
|
api_key = cfg.api_key or ""
|
||||||
|
model = cfg.model or "gpt-4o-mini"
|
||||||
|
timeout = float(cfg.extra.get("timeout", 30))
|
||||||
|
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
# 1) 尝试 /models(Ollama、OpenAI 兼容)
|
||||||
|
models_url = f"{base_url}/models"
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(models_url, headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
ids = _extract_model_ids(data)
|
||||||
|
if model and ids and model not in ids:
|
||||||
|
return (
|
||||||
|
f"{label} 服务可达",
|
||||||
|
f"已连接 /models,但未找到模型「{model}」。可用: {', '.join(ids[:8])}",
|
||||||
|
)
|
||||||
|
return f"{label} 服务可达", f"已连接 /models,目标模型: {model}"
|
||||||
|
except httpx.HTTPError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2) 最小 chat 探活
|
||||||
|
chat_url = f"{base_url}/chat/completions"
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": "请只回复 OK"}],
|
||||||
|
"max_tokens": 16,
|
||||||
|
"temperature": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = await client.post(chat_url, json=payload, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
return f"{label} 对话成功", f"模型 {model} 回复: {str(content).strip()[:60]}"
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
body = exc.response.text[:200]
|
||||||
|
raise ProviderTestError(
|
||||||
|
f"API 返回 {exc.response.status_code}: {body}"
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProviderTestError(f"无法连接 {base_url}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_model_ids(data: Any) -> list[str]:
|
||||||
|
"""从 /models 响应中提取 model id 列表。"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
items = data.get("data") or data.get("models") or []
|
||||||
|
ids: list[str] = []
|
||||||
|
if isinstance(items, list):
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
mid = item.get("id") or item.get("name") or item.get("model")
|
||||||
|
if mid:
|
||||||
|
ids.append(str(mid))
|
||||||
|
elif isinstance(item, str):
|
||||||
|
ids.append(item)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def merge_provider_api_key(cfg: ProviderConfig, existing: dict | None) -> ProviderConfig:
|
||||||
|
"""测试时若 api_key 为空,合并已保存的 key。"""
|
||||||
|
payload = cfg.model_dump()
|
||||||
|
if (not payload.get("api_key")) and isinstance(existing, dict):
|
||||||
|
payload["api_key"] = existing.get("api_key", "")
|
||||||
|
return ProviderConfig(**payload)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""截图列表搜索:FTS + 子串模糊(兼容中文标签/标题)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.meta import ScreenshotMeta
|
||||||
|
from app.models.screenshot import Screenshot
|
||||||
|
from app.models.tag import Tag
|
||||||
|
|
||||||
|
|
||||||
|
def fts_query_string(raw: str) -> str:
|
||||||
|
"""把用户输入处理成 FTS5 查询串(中英文均支持前缀匹配)。"""
|
||||||
|
parts = [p for p in raw.replace("\n", " ").split() if p]
|
||||||
|
if not parts:
|
||||||
|
return raw
|
||||||
|
cleaned: list[str] = []
|
||||||
|
for p in parts:
|
||||||
|
p = p.replace('"', "").strip()
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
cleaned.append(f'"{p}"*')
|
||||||
|
return " ".join(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_search_ids(session: Session, q: str, *, limit: int = 5000) -> set[int]:
|
||||||
|
"""联合 FTS5 与 LIKE 子串搜索,返回匹配的 screenshot id 集合。"""
|
||||||
|
q = q.strip()
|
||||||
|
if not q:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
ids: set[int] = set()
|
||||||
|
like = f"%{q}%"
|
||||||
|
|
||||||
|
# 1) FTS5 全文索引
|
||||||
|
try:
|
||||||
|
fts_sql = text(
|
||||||
|
"SELECT rowid FROM screenshots_fts WHERE screenshots_fts MATCH :q LIMIT :lim"
|
||||||
|
)
|
||||||
|
rows = session.execute(fts_sql, {"q": fts_query_string(q), "lim": limit}).fetchall()
|
||||||
|
ids.update(int(row[0]) for row in rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2) 子串模糊:OCR/AI 文本(解决「三花」匹配「三花猫」)
|
||||||
|
meta_ids = session.scalars(
|
||||||
|
select(ScreenshotMeta.screenshot_id).where(
|
||||||
|
or_(
|
||||||
|
ScreenshotMeta.ocr_text.ilike(like),
|
||||||
|
ScreenshotMeta.ai_title.ilike(like),
|
||||||
|
ScreenshotMeta.ai_summary.ilike(like),
|
||||||
|
ScreenshotMeta.ai_suggestion.ilike(like),
|
||||||
|
)
|
||||||
|
).limit(limit)
|
||||||
|
).all()
|
||||||
|
ids.update(int(i) for i in meta_ids)
|
||||||
|
|
||||||
|
# 3) 标签名子串匹配
|
||||||
|
tag_ids = session.scalars(
|
||||||
|
select(Screenshot.id)
|
||||||
|
.join(Screenshot.tags)
|
||||||
|
.where(Tag.name.ilike(like))
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
ids.update(int(i) for i in tag_ids)
|
||||||
|
|
||||||
|
return ids
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""读取/写入键值设置。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.setting import Setting
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(session: Session, key: str, default: Any = None) -> Any:
|
||||||
|
"""读取并 JSON 解析。"""
|
||||||
|
row = session.get(Setting, key)
|
||||||
|
if row is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return json.loads(row.value_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(session: Session, key: str, value: Any) -> None:
|
||||||
|
"""JSON 序列化后落库(upsert)。"""
|
||||||
|
row = session.get(Setting, key)
|
||||||
|
payload = json.dumps(value, ensure_ascii=False)
|
||||||
|
if row is None:
|
||||||
|
session.add(Setting(key=key, value_json=payload))
|
||||||
|
else:
|
||||||
|
row.value_json = payload
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def all_settings(session: Session) -> dict[str, Any]:
|
||||||
|
"""返回所有设置,给前端调试 / 导出。"""
|
||||||
|
items: dict[str, Any] = {}
|
||||||
|
for row in session.query(Setting).all():
|
||||||
|
try:
|
||||||
|
items[row.key] = json.loads(row.value_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
items[row.key] = row.value_json
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_config(session: Session, key: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""便捷读取 OCR/VLM provider 配置 dict。"""
|
||||||
|
value = get_setting(session, key, None)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""生成并缓存缩略图。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.path_utils import path_to_storage
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported(path: Path) -> bool:
|
||||||
|
"""是否为我们支持的图片格式。"""
|
||||||
|
return path.suffix.lower() in SUPPORTED_EXTS
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail(image_path: Path, max_side: int | None = None) -> Path:
|
||||||
|
"""生成 webp 缩略图,落到缓存目录。返回缓存路径。"""
|
||||||
|
max_side = max_side or settings.thumb_size
|
||||||
|
# 用文件路径 + mtime 哈希作为缓存键,源文件变化会自动生成新缩略图
|
||||||
|
stat = image_path.stat()
|
||||||
|
key = hashlib.md5(
|
||||||
|
f"{path_to_storage(image_path)}|{stat.st_mtime_ns}|{max_side}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
out = settings.thumb_dir / f"{key}.webp"
|
||||||
|
if out.exists():
|
||||||
|
return out
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.thumbnail((max_side, max_side), Image.LANCZOS)
|
||||||
|
img.save(out, format="WEBP", quality=80)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def file_hash(image_path: Path, chunk: int = 1024 * 1024) -> str:
|
||||||
|
"""计算文件 sha256,用作去重键。"""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
data = f.read(chunk)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
h.update(data)
|
||||||
|
return h.hexdigest()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""watchdog 监听被关注的目录。
|
||||||
|
|
||||||
|
中文路径与 OneDrive 同步盘下 NTFS 事件偶发不稳,因此默认使用 PollingObserver。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||||
|
from watchdog.observers.polling import PollingObserver
|
||||||
|
|
||||||
|
from app.core.db import session_scope
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.core.path_utils import is_accessible_dir, path_from_storage
|
||||||
|
from app.models.watch_folder import WatchFolder
|
||||||
|
from app.services.ingest import ingest_path
|
||||||
|
from app.services.thumbnail import is_supported
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _ScreenshotEventHandler(FileSystemEventHandler):
|
||||||
|
"""新文件 -> 入库 -> 触发分析。"""
|
||||||
|
|
||||||
|
def __init__(self, loop: asyncio.AbstractEventLoop, notify) -> None: # noqa: ANN001
|
||||||
|
self._loop = loop
|
||||||
|
self._notify = notify
|
||||||
|
|
||||||
|
def on_created(self, event: FileSystemEvent) -> None:
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
self._handle(Path(event.src_path))
|
||||||
|
|
||||||
|
def on_moved(self, event: FileSystemEvent) -> None:
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
self._handle(Path(getattr(event, "dest_path", event.src_path)))
|
||||||
|
|
||||||
|
def _handle(self, path: Path) -> None:
|
||||||
|
if not is_supported(path):
|
||||||
|
return
|
||||||
|
# 等待写入完成(截图工具常会先创建空文件再写入)
|
||||||
|
try:
|
||||||
|
self._wait_file_ready(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
with session_scope() as session:
|
||||||
|
shot = ingest_path(session, path)
|
||||||
|
if shot is not None:
|
||||||
|
asyncio.run_coroutine_threadsafe(self._notify(), self._loop)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wait_file_ready(path: Path, retries: int = 10, interval: float = 0.3) -> None:
|
||||||
|
"""轮询直至文件大小稳定。"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
last = -1
|
||||||
|
for _ in range(retries):
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size > 0 and size == last:
|
||||||
|
return
|
||||||
|
last = size
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
class WatcherService:
|
||||||
|
"""管理多个监听目录的生命周期。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._observer: PollingObserver | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._notify_cb = None
|
||||||
|
|
||||||
|
def start(self, loop: asyncio.AbstractEventLoop, notify) -> None: # noqa: ANN001
|
||||||
|
"""根据数据库中的目录列表启动监听。"""
|
||||||
|
with self._lock:
|
||||||
|
self._loop = loop
|
||||||
|
self._notify_cb = notify
|
||||||
|
self._stop_locked()
|
||||||
|
self._observer = PollingObserver(timeout=2.0)
|
||||||
|
handler = _ScreenshotEventHandler(loop, notify)
|
||||||
|
with session_scope() as session:
|
||||||
|
folders = session.scalars(
|
||||||
|
select(WatchFolder).where(WatchFolder.enabled.is_(True))
|
||||||
|
).all()
|
||||||
|
paths = [(f.path, f.recursive) for f in folders]
|
||||||
|
for path, recursive in paths:
|
||||||
|
p = path_from_storage(path)
|
||||||
|
if not is_accessible_dir(p):
|
||||||
|
logger.warning("监听目录不存在或不可访问,跳过: %s", path)
|
||||||
|
continue
|
||||||
|
logger.info("开始监听 %s (recursive=%s)", path, recursive)
|
||||||
|
self._observer.schedule(handler, str(p), recursive=recursive)
|
||||||
|
self._observer.start()
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
"""监听目录变更后重启。"""
|
||||||
|
if self._loop is None or self._notify_cb is None:
|
||||||
|
return
|
||||||
|
self.start(self._loop, self._notify_cb)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._stop_locked()
|
||||||
|
|
||||||
|
def _stop_locked(self) -> None:
|
||||||
|
if self._observer is not None:
|
||||||
|
try:
|
||||||
|
self._observer.stop()
|
||||||
|
self._observer.join(timeout=3)
|
||||||
|
finally:
|
||||||
|
self._observer = None
|
||||||
|
|
||||||
|
|
||||||
|
watcher_service = WatcherService()
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""异步任务调度器:从 jobs 表取任务并并发执行。
|
||||||
|
|
||||||
|
事务规则:
|
||||||
|
- 调度循环只用短事务 claim 任务、汇总状态。
|
||||||
|
- 真正的 OCR/VLM 调用由 `analyze_screenshot_by_id` 自己管理短事务,
|
||||||
|
绝不在 worker 这一层包裹长事务。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import case, func, or_, select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.db import session_scope
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.models.job import Job, JobKind, JobStatus
|
||||||
|
from app.models.screenshot import ProcessStatus, Screenshot
|
||||||
|
from app.services.analyze import analyze_ocr_only_by_id, analyze_screenshot_by_id
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeWorker:
|
||||||
|
"""单实例后台 worker,负责把 jobs 表中的待处理项跑完。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._event = asyncio.Event()
|
||||||
|
self._stop = False
|
||||||
|
self._semaphore = asyncio.Semaphore(settings.analyze_concurrency)
|
||||||
|
self._inflight: int = 0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""启动主循环。"""
|
||||||
|
# 启动时把上次中断的 RUNNING 任务复位
|
||||||
|
with session_scope() as session:
|
||||||
|
running = session.scalars(
|
||||||
|
select(Job).where(Job.status == JobStatus.RUNNING.value)
|
||||||
|
).all()
|
||||||
|
for job in running:
|
||||||
|
job.status = JobStatus.PENDING.value
|
||||||
|
self._stop = False
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self._task = asyncio.create_task(self._run(), name="analyze-worker")
|
||||||
|
self.notify()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._stop = True
|
||||||
|
self._event.set()
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def notify(self) -> None:
|
||||||
|
"""在事件循环线程内通知 worker 有新任务可取。"""
|
||||||
|
self._event.set()
|
||||||
|
|
||||||
|
def notify_threadsafe(self) -> None:
|
||||||
|
"""跨线程唤醒 worker(FastAPI BackgroundTasks / watcher 线程)。
|
||||||
|
|
||||||
|
asyncio.Event.set() 本身不是线程安全的,必须通过
|
||||||
|
loop.call_soon_threadsafe 调度回事件循环线程。
|
||||||
|
"""
|
||||||
|
loop = self._loop
|
||||||
|
if loop is None or loop.is_closed():
|
||||||
|
return
|
||||||
|
loop.call_soon_threadsafe(self._event.set)
|
||||||
|
|
||||||
|
async def status(self) -> dict[str, int]:
|
||||||
|
"""供 API 查询当前队列状况(按 status 索引计数,适合大批量)。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
pending = session.scalar(
|
||||||
|
select(Job.id)
|
||||||
|
.where(Job.status == JobStatus.PENDING.value)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
rows = session.execute(
|
||||||
|
select(Job.status, func.count())
|
||||||
|
.group_by(Job.status)
|
||||||
|
).all()
|
||||||
|
counts = {st.value: 0 for st in JobStatus}
|
||||||
|
for status, cnt in rows:
|
||||||
|
counts[status] = int(cnt)
|
||||||
|
counts["inflight"] = self._inflight
|
||||||
|
counts["has_more"] = 1 if pending is not None else 0
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def reset_stale_running(self, *, minutes: int = 5, reset_all: bool = False) -> int:
|
||||||
|
"""把长时间 RUNNING 且无进展的任务复位为 PENDING。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
q = select(Job).where(Job.status == JobStatus.RUNNING.value)
|
||||||
|
if not reset_all:
|
||||||
|
cutoff = datetime.utcnow() - timedelta(minutes=max(minutes, 1))
|
||||||
|
q = q.where(Job.started_at.is_not(None), Job.started_at < cutoff)
|
||||||
|
stale = session.scalars(q).all()
|
||||||
|
for job in stale:
|
||||||
|
job.status = JobStatus.PENDING.value
|
||||||
|
job.started_at = None
|
||||||
|
count = len(stale)
|
||||||
|
if count:
|
||||||
|
logger.info("复位 %d 条 RUNNING 任务为 PENDING", count)
|
||||||
|
self.notify()
|
||||||
|
return count
|
||||||
|
|
||||||
|
def retry_failed(self, job_ids: Optional[list[int]] = None) -> int:
|
||||||
|
"""将 failed 任务重新排队。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
q = select(Job).where(Job.status == JobStatus.FAILED.value)
|
||||||
|
if job_ids:
|
||||||
|
q = q.where(Job.id.in_(job_ids))
|
||||||
|
failed = session.scalars(q).all()
|
||||||
|
for job in failed:
|
||||||
|
job.status = JobStatus.PENDING.value
|
||||||
|
job.retries = 0
|
||||||
|
job.last_error = None
|
||||||
|
job.started_at = None
|
||||||
|
job.finished_at = None
|
||||||
|
count = len(failed)
|
||||||
|
if count:
|
||||||
|
logger.info("重试 %d 条 failed 任务", count)
|
||||||
|
self.notify()
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
"""主循环。"""
|
||||||
|
idle_rounds = 0
|
||||||
|
while not self._stop:
|
||||||
|
job = self._claim_one()
|
||||||
|
if job is None:
|
||||||
|
idle_rounds += 1
|
||||||
|
# 空闲时定期清理僵尸 RUNNING,避免 inflight=0 但 DB 仍显示 running
|
||||||
|
if idle_rounds >= 3 and self._inflight == 0:
|
||||||
|
idle_rounds = 0
|
||||||
|
if self.reset_stale_running(minutes=5):
|
||||||
|
continue
|
||||||
|
self._event.clear()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._event.wait(), timeout=10)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
idle_rounds = 0
|
||||||
|
await self._semaphore.acquire()
|
||||||
|
async with self._lock:
|
||||||
|
self._inflight += 1
|
||||||
|
asyncio.create_task(
|
||||||
|
self._process(job["id"], job["screenshot_id"], job["kind"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def _claim_one(self) -> Optional[dict]:
|
||||||
|
"""短事务:取一条 PENDING 任务;FULL 优先于 OCR 补跑。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
job = session.scalar(
|
||||||
|
select(Job)
|
||||||
|
.where(
|
||||||
|
Job.status == JobStatus.PENDING.value,
|
||||||
|
or_(Job.retries < settings.max_retries, Job.retries.is_(None)),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
case(
|
||||||
|
(Job.kind == JobKind.FULL.value, 0),
|
||||||
|
(Job.kind == JobKind.VLM.value, 1),
|
||||||
|
else_=2,
|
||||||
|
),
|
||||||
|
Job.id.asc(),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if job is None:
|
||||||
|
return None
|
||||||
|
job.status = JobStatus.RUNNING.value
|
||||||
|
job.started_at = datetime.utcnow()
|
||||||
|
session.flush()
|
||||||
|
return {
|
||||||
|
"id": job.id,
|
||||||
|
"screenshot_id": job.screenshot_id,
|
||||||
|
"kind": job.kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _process(self, job_id: int, screenshot_id: int, kind: str) -> None:
|
||||||
|
"""执行单个任务,所有 DB 写入均在短事务中。"""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
if kind == JobKind.OCR.value:
|
||||||
|
await analyze_ocr_only_by_id(screenshot_id)
|
||||||
|
else:
|
||||||
|
await analyze_screenshot_by_id(screenshot_id)
|
||||||
|
self._finish(job_id, success=True, kind=kind)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("分析失败 #%d (%s): %s", screenshot_id, kind, exc)
|
||||||
|
self._finish(job_id, success=False, error=str(exc), kind=kind)
|
||||||
|
finally:
|
||||||
|
self._semaphore.release()
|
||||||
|
async with self._lock:
|
||||||
|
self._inflight -= 1
|
||||||
|
self.notify()
|
||||||
|
|
||||||
|
def _finish(
|
||||||
|
self,
|
||||||
|
job_id: int,
|
||||||
|
success: bool,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
kind: str = JobKind.FULL.value,
|
||||||
|
) -> None:
|
||||||
|
"""短事务:更新 jobs 表完成状态。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
job = session.get(Job, job_id)
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
if success:
|
||||||
|
job.status = JobStatus.DONE.value
|
||||||
|
job.last_error = None
|
||||||
|
else:
|
||||||
|
job.retries = (job.retries or 0) + 1
|
||||||
|
if job.retries >= settings.max_retries:
|
||||||
|
job.status = JobStatus.FAILED.value
|
||||||
|
# OCR 补跑失败不影响 ai_status
|
||||||
|
if kind != JobKind.OCR.value:
|
||||||
|
shot = session.get(Screenshot, job.screenshot_id)
|
||||||
|
if shot is not None:
|
||||||
|
shot.ai_status = ProcessStatus.FAILED.value
|
||||||
|
else:
|
||||||
|
job.status = JobStatus.PENDING.value
|
||||||
|
job.last_error = (error or "")[:1000]
|
||||||
|
job.finished_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
worker = AnalyzeWorker()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
sqlalchemy==2.0.34
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
python-multipart==0.0.10
|
||||||
|
watchdog==5.0.2
|
||||||
|
Pillow==10.4.0
|
||||||
|
pytesseract==0.3.13
|
||||||
|
httpx==0.27.2
|
||||||
|
aiofiles==24.1.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""开发入口:python run.py 启动后端。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
reload=settings.debug,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>snapAna · 截图分析</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2813
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "snapana-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.451.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"react-window": "^1.8.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.10",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NavLink, Route, Routes, Navigate } from "react-router-dom";
|
||||||
|
import { Home, Images, Shuffle, ListChecks, Settings as Cog, ListOrdered, Hash } from "lucide-react";
|
||||||
|
|
||||||
|
import HomePage from "@/pages/Home";
|
||||||
|
import LibraryPage from "@/pages/Library";
|
||||||
|
import ShufflePage from "@/pages/Shuffle";
|
||||||
|
import TodosPage from "@/pages/Todos";
|
||||||
|
import SettingsPage from "@/pages/Settings";
|
||||||
|
import QueuePage from "@/pages/Queue";
|
||||||
|
import TagsPage from "@/pages/Tags";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", label: "首页", icon: Home },
|
||||||
|
{ to: "/library", label: "库", icon: Images },
|
||||||
|
{ to: "/tags", label: "标签", icon: Hash },
|
||||||
|
{ to: "/queue", label: "队列", icon: ListOrdered },
|
||||||
|
{ to: "/shuffle", label: "随机", icon: Shuffle },
|
||||||
|
{ to: "/todos", label: "待办", icon: ListChecks },
|
||||||
|
{ to: "/settings", label: "设置", icon: Cog },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
<aside className="flex w-56 shrink-0 flex-col border-r border-slate-800 bg-slate-950/80">
|
||||||
|
<div className="px-5 py-5">
|
||||||
|
<div className="text-lg font-semibold tracking-wide text-white">snapAna</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">截图智能整理</div>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col gap-1 px-3">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === "/"}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 rounded-md px-3 py-2 text-sm transition ${
|
||||||
|
isActive
|
||||||
|
? "bg-brand-600/20 text-white"
|
||||||
|
: "text-slate-400 hover:bg-slate-800/60 hover:text-white"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon size={16} />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="mt-auto px-5 py-4 text-xs text-slate-500">
|
||||||
|
v0.1 · 本地运行
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/library" element={<LibraryPage />} />
|
||||||
|
<Route path="/tags" element={<TagsPage />} />
|
||||||
|
<Route path="/queue" element={<QueuePage />} />
|
||||||
|
<Route path="/shuffle" element={<ShufflePage />} />
|
||||||
|
<Route path="/todos" element={<TodosPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type {
|
||||||
|
Category,
|
||||||
|
JobListResp,
|
||||||
|
ListQuery,
|
||||||
|
ListResp,
|
||||||
|
ProviderConfig,
|
||||||
|
ProviderConfigOut,
|
||||||
|
ProviderTestResult,
|
||||||
|
ScreenshotBrief,
|
||||||
|
ScreenshotDetail,
|
||||||
|
StatsResp,
|
||||||
|
Tag,
|
||||||
|
TagListResp,
|
||||||
|
TodoItem,
|
||||||
|
TodoListQuery,
|
||||||
|
TodoListResp,
|
||||||
|
WatchFolder,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
const BASE = "";
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit & { params?: Record<string, unknown> }
|
||||||
|
): Promise<T> {
|
||||||
|
const { params, ...rest } = init ?? {};
|
||||||
|
const url = new URL(BASE + path, window.location.origin);
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null || value === "") return;
|
||||||
|
url.searchParams.append(key, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const resp = await fetch(url.pathname + url.search, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(rest.headers ?? {}),
|
||||||
|
},
|
||||||
|
...rest,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
let detail = resp.statusText;
|
||||||
|
try {
|
||||||
|
const data = await resp.json();
|
||||||
|
detail = data.detail ?? detail;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
if (resp.status === 204) return undefined as T;
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
listScreenshots: (query: ListQuery) =>
|
||||||
|
request<ListResp>("/api/screenshots", { params: query as Record<string, unknown> }),
|
||||||
|
getScreenshot: (id: number) => request<ScreenshotDetail>(`/api/screenshots/${id}`),
|
||||||
|
randomScreenshots: (params: { n?: number; category_id?: number }) =>
|
||||||
|
request<ScreenshotBrief[]>("/api/screenshots/random", {
|
||||||
|
params: params as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
stats: () => request<StatsResp>("/api/screenshots/stats"),
|
||||||
|
reanalyze: (id: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/screenshots/${id}/reanalyze`, { method: "POST" }),
|
||||||
|
reocr: (id: number) =>
|
||||||
|
request<{ ok: boolean; job_id?: number; message?: string }>(
|
||||||
|
`/api/screenshots/${id}/reocr`,
|
||||||
|
{ method: "POST" }
|
||||||
|
),
|
||||||
|
updateScreenshot: (id: number, payload: Partial<{ category_id: number | null; is_favorite: boolean; is_hidden: boolean; tags: string[] }>) =>
|
||||||
|
request<ScreenshotDetail>(`/api/screenshots/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
deleteScreenshot: (id: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/screenshots/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
listTodos: (params: TodoListQuery) =>
|
||||||
|
request<TodoListResp>("/api/todos", { params: params as Record<string, unknown> }),
|
||||||
|
todoSummary: () => request<Record<string, number>>("/api/todos/summary"),
|
||||||
|
updateTodo: (id: number, payload: Partial<{ status: string; title: string; note: string }>) =>
|
||||||
|
request<TodoItem>(`/api/todos/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
deleteTodo: (id: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/todos/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
listWatchFolders: () => request<WatchFolder[]>("/api/watch/folders"),
|
||||||
|
addWatchFolder: (payload: Omit<WatchFolder, "id">) =>
|
||||||
|
request<WatchFolder>("/api/watch/folders", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
updateWatchFolder: (id: number, payload: Omit<WatchFolder, "id">) =>
|
||||||
|
request<WatchFolder>(`/api/watch/folders/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
deleteWatchFolder: (id: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/watch/folders/${id}`, { method: "DELETE" }),
|
||||||
|
importNow: (payload: Omit<WatchFolder, "id">) =>
|
||||||
|
request<{ ok: boolean }>("/api/watch/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
validateWatchPath: (path: string) =>
|
||||||
|
request<{ ok: boolean; path: string; sample_image_count: number; samples: string[]; message: string }>(
|
||||||
|
"/api/watch/validate-path",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ path, enabled: true, recursive: true, is_sensitive: false }),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
queueStatus: () => request<Record<string, number>>("/api/watch/queue"),
|
||||||
|
listJobs: (params: { status?: string; kind?: string; page?: number; size?: number }) =>
|
||||||
|
request<JobListResp>("/api/watch/jobs", {
|
||||||
|
params: params as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
retryFailedJobs: (jobIds?: number[]) =>
|
||||||
|
request<{ ok: boolean; count: number }>("/api/watch/jobs/retry-failed", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(jobIds?.length ? { job_ids: jobIds } : {}),
|
||||||
|
}),
|
||||||
|
resetStaleJobs: (resetAll?: boolean) =>
|
||||||
|
request<{ ok: boolean; count: number }>("/api/watch/jobs/reset-stale", {
|
||||||
|
method: "POST",
|
||||||
|
params: { reset_all: resetAll ? true : undefined },
|
||||||
|
}),
|
||||||
|
enqueueOcrFailed: (limit?: number) =>
|
||||||
|
request<{ ok: boolean; count: number }>("/api/watch/jobs/enqueue-ocr-failed", {
|
||||||
|
method: "POST",
|
||||||
|
params: { limit: limit ?? 500 },
|
||||||
|
}),
|
||||||
|
|
||||||
|
listCategories: () => request<Category[]>("/api/settings/categories"),
|
||||||
|
createCategory: (payload: Omit<Category, "id">) =>
|
||||||
|
request<{ id: number }>("/api/settings/categories", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
updateCategory: (id: number, payload: Omit<Category, "id">) =>
|
||||||
|
request<{ ok: boolean }>(`/api/settings/categories/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
deleteCategory: (id: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/settings/categories/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
listTags: (params?: { q?: string; page?: number; size?: number; sort?: string }) =>
|
||||||
|
request<TagListResp>("/api/settings/tags", {
|
||||||
|
params: (params ?? {}) as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getProvider: (key: "ocr_provider" | "vlm_provider") =>
|
||||||
|
request<ProviderConfigOut | null>(`/api/settings/providers/${key}`),
|
||||||
|
setProvider: (key: "ocr_provider" | "vlm_provider", payload: ProviderConfig) =>
|
||||||
|
request<{ ok: boolean }>(`/api/settings/providers/${key}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
testProvider: (key: "ocr_provider" | "vlm_provider", payload: ProviderConfig) =>
|
||||||
|
request<ProviderTestResult>(`/api/settings/providers/${key}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getRecognitionMode: () =>
|
||||||
|
request<{ mode: string; options: string[] }>("/api/settings/recognition-mode"),
|
||||||
|
setRecognitionMode: (mode: string) =>
|
||||||
|
request<{ ok: boolean; mode: string }>("/api/settings/recognition-mode", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ mode }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Star } from "lucide-react";
|
||||||
|
import type { ScreenshotBrief } from "@/types";
|
||||||
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shot: ScreenshotBrief;
|
||||||
|
onOpen: (id: number) => void;
|
||||||
|
onToggleFav?: (shot: ScreenshotBrief) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ shot, onOpen, onToggleFav }: Props) {
|
||||||
|
const date = new Date(shot.captured_at).toLocaleString("zh-CN", {
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onOpen(shot.id)}
|
||||||
|
className="card-hover group relative flex w-full flex-col overflow-hidden rounded-xl border border-slate-800 bg-slate-900/40 text-left"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[4/3] w-full overflow-hidden bg-slate-800">
|
||||||
|
{shot.thumb_url ? (
|
||||||
|
<img
|
||||||
|
src={shot.thumb_url}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-xs text-slate-500">
|
||||||
|
无缩略图
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shot.category && (
|
||||||
|
<span
|
||||||
|
className="absolute left-2 top-2 inline-flex items-center rounded-full bg-black/55 px-2 py-0.5 text-[10px] text-white backdrop-blur"
|
||||||
|
style={{
|
||||||
|
borderLeft: `3px solid ${shot.category.color ?? "#6366f1"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shot.category.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`absolute right-2 top-2 rounded-full p-1.5 transition ${
|
||||||
|
shot.is_favorite
|
||||||
|
? "bg-amber-400/90 text-slate-900"
|
||||||
|
: "bg-black/40 text-slate-300 opacity-0 group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleFav?.(shot);
|
||||||
|
}}
|
||||||
|
aria-label="收藏"
|
||||||
|
>
|
||||||
|
<Star size={14} fill={shot.is_favorite ? "currentColor" : "none"} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-1 px-3 py-2">
|
||||||
|
<div className="line-clamp-2 text-sm font-medium text-slate-100">
|
||||||
|
{shot.ai_title || "(未生成标题)"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span>{date}</span>
|
||||||
|
<StatusBadge status={shot.ai_status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { FixedSizeGrid as Grid } from "react-window";
|
||||||
|
|
||||||
|
import type { ScreenshotBrief } from "@/types";
|
||||||
|
import { Card } from "./Card";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: ScreenshotBrief[];
|
||||||
|
onOpen: (id: number) => void;
|
||||||
|
onToggleFav?: (shot: ScreenshotBrief) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_W = 260;
|
||||||
|
const CARD_H = 280;
|
||||||
|
const GAP = 16;
|
||||||
|
|
||||||
|
export function CardGrid({ items, onOpen, onToggleFav }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
setSize({ w: entry.contentRect.width, h: entry.contentRect.height });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colCount = useMemo(() => {
|
||||||
|
const c = Math.max(1, Math.floor((size.w + GAP) / (CARD_W + GAP)));
|
||||||
|
return c;
|
||||||
|
}, [size.w]);
|
||||||
|
|
||||||
|
const rowCount = Math.ceil(items.length / colCount);
|
||||||
|
const colWidth = (size.w - GAP) / Math.max(colCount, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative h-full w-full">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-slate-500">
|
||||||
|
没有匹配的截图
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Grid
|
||||||
|
columnCount={colCount}
|
||||||
|
rowCount={rowCount}
|
||||||
|
columnWidth={colWidth}
|
||||||
|
rowHeight={CARD_H + GAP}
|
||||||
|
height={size.h}
|
||||||
|
width={size.w}
|
||||||
|
itemKey={({ rowIndex, columnIndex }) => {
|
||||||
|
const idx = rowIndex * colCount + columnIndex;
|
||||||
|
const item = items[idx];
|
||||||
|
return item ? `s-${item.id}` : `e-${rowIndex}-${columnIndex}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ rowIndex, columnIndex, style }) => {
|
||||||
|
const idx = rowIndex * colCount + columnIndex;
|
||||||
|
const item = items[idx];
|
||||||
|
if (!item) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
paddingRight: GAP,
|
||||||
|
paddingBottom: GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card shot={item} onOpen={onOpen} onToggleFav={onToggleFav} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Copy, ExternalLink, RefreshCw, Star, Trash2, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import type { ScreenshotDetail } from "@/types";
|
||||||
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailPanel({ id, onClose }: Props) {
|
||||||
|
const open = id !== null;
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ["screenshot", id],
|
||||||
|
queryFn: () => api.getScreenshot(id!),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: (payload: Parameters<typeof api.updateScreenshot>[1]) =>
|
||||||
|
api.updateScreenshot(id!, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["screenshot", id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["screenshots"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reanalyze = useMutation({
|
||||||
|
mutationFn: () => api.reanalyze(id!),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["screenshot", id] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reocr = useMutation({
|
||||||
|
mutationFn: () => api.reocr(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["screenshot", id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["queue"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => api.deleteScreenshot(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["screenshots"] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cats = useQuery({ queryKey: ["categories"], queryFn: api.listCategories });
|
||||||
|
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTagInput("");
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const s = detail.data as ScreenshotDetail | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex justify-end">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
<div className="glass relative flex h-full w-full max-w-4xl flex-col overflow-hidden border-l border-slate-800">
|
||||||
|
<header className="flex items-center justify-between border-b border-slate-800 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<span className="rounded bg-slate-800 px-2 py-0.5 text-xs">#{id}</span>
|
||||||
|
{s && <StatusBadge status={s.ai_status} />}
|
||||||
|
{s?.category && (
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${s.category.color ?? "#6366f1"}22`,
|
||||||
|
color: s.category.color ?? "#a5b4fc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.category.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="btn-ghost rounded p-1.5" onClick={onClose} aria-label="关闭">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{s ? (
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<div className="flex flex-1 items-center justify-center bg-black/40 p-4">
|
||||||
|
<img
|
||||||
|
src={s.file_url}
|
||||||
|
alt={s.ai_title ?? "screenshot"}
|
||||||
|
className="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-[420px] shrink-0 overflow-y-auto border-l border-slate-800 px-4 py-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
className={`btn ${s.is_favorite ? "btn-primary" : ""}`}
|
||||||
|
onClick={() => update.mutate({ is_favorite: !s.is_favorite })}
|
||||||
|
>
|
||||||
|
<Star size={14} /> {s.is_favorite ? "已收藏" : "收藏"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => reanalyze.mutate()}
|
||||||
|
disabled={reanalyze.isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} /> 重新分析
|
||||||
|
</button>
|
||||||
|
{s.ocr_status === "failed" && s.ai_status === "done" && (
|
||||||
|
<button
|
||||||
|
className="btn border-brand-600/50 text-brand-300"
|
||||||
|
onClick={() => reocr.mutate()}
|
||||||
|
disabled={reocr.isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} /> 补跑 OCR
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
className="btn"
|
||||||
|
href={s.file_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} /> 原图
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="btn text-rose-300 hover:border-rose-500"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("从库中移除(不删除原文件)?")) remove.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> 移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="标题">
|
||||||
|
<p className="text-base font-medium text-white">
|
||||||
|
{s.ai_title || "(未生成标题)"}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="AI 摘要">
|
||||||
|
<p className="whitespace-pre-line text-sm text-slate-300">
|
||||||
|
{s.ai_summary || "—"}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{s.ai_suggestion && (
|
||||||
|
<Section title="AI 建议">
|
||||||
|
<p className="whitespace-pre-line text-sm text-amber-200/90">
|
||||||
|
{s.ai_suggestion}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="OCR 文本"
|
||||||
|
right={
|
||||||
|
<button
|
||||||
|
className="btn-ghost rounded p-1"
|
||||||
|
onClick={() => navigator.clipboard.writeText(s.ocr_text ?? "")}
|
||||||
|
aria-label="复制 OCR 文本"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-slate-800 bg-slate-900/60 p-2 text-xs text-slate-300">
|
||||||
|
{s.ocr_text || "(无)"}
|
||||||
|
</pre>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="分类">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={s.category?.id ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
update.mutate({
|
||||||
|
category_id: e.target.value ? Number(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">未分类</option>
|
||||||
|
{cats.data?.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="标签">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{s.tags.map((t) => (
|
||||||
|
<span key={t.id} className="chip">
|
||||||
|
#{t.name}
|
||||||
|
<button
|
||||||
|
className="text-slate-500 hover:text-rose-300"
|
||||||
|
onClick={() =>
|
||||||
|
update.mutate({
|
||||||
|
tags: s.tags.filter((x) => x.id !== t.id).map((x) => x.name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="新增标签后回车"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && tagInput.trim()) {
|
||||||
|
update.mutate({
|
||||||
|
tags: [
|
||||||
|
...s.tags.map((t) => t.name),
|
||||||
|
tagInput.trim(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setTagInput("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{s.todos.length > 0 && (
|
||||||
|
<Section title="待办">
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{s.todos.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.id}
|
||||||
|
className="rounded-md border border-slate-800 bg-slate-900/60 p-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-slate-100">{t.title}</span>
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
{t.kind} · {t.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{t.note && (
|
||||||
|
<div className="mt-1 text-xs text-slate-400">{t.note}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="元信息">
|
||||||
|
<div className="grid grid-cols-2 gap-y-1 text-xs text-slate-400">
|
||||||
|
<div>尺寸</div>
|
||||||
|
<div className="text-slate-200">
|
||||||
|
{s.width}×{s.height}
|
||||||
|
</div>
|
||||||
|
<div>体积</div>
|
||||||
|
<div className="text-slate-200">
|
||||||
|
{(s.size / 1024).toFixed(1)} KB
|
||||||
|
</div>
|
||||||
|
<div>路径</div>
|
||||||
|
<div className="break-all text-slate-200">{s.path}</div>
|
||||||
|
<div>截取时间</div>
|
||||||
|
<div className="text-slate-200">
|
||||||
|
{new Date(s.captured_at).toLocaleString("zh-CN", {
|
||||||
|
hour12: false,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-slate-500">
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
right,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Search, Star, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import type { ListQuery } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
query: ListQuery;
|
||||||
|
onChange: (next: ListQuery) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({ query, onChange }: Props) {
|
||||||
|
const cats = useQuery({ queryKey: ["categories"], queryFn: api.listCategories });
|
||||||
|
const tags = useQuery({
|
||||||
|
queryKey: ["tags", "top"],
|
||||||
|
queryFn: () => api.listTags({ size: 30, sort: "count_desc" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = (next: Partial<ListQuery>) => onChange({ ...query, ...next, page: 1 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex w-64 shrink-0 flex-col gap-5 overflow-y-auto border-r border-slate-800 bg-slate-950/60 px-4 py-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-slate-400">关键词搜索</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input pl-7"
|
||||||
|
placeholder="OCR / 标题 / 标签"
|
||||||
|
value={query.q ?? ""}
|
||||||
|
onChange={(e) => patch({ q: e.target.value })}
|
||||||
|
/>
|
||||||
|
{query.q && (
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
|
||||||
|
onClick={() => patch({ q: "" })}
|
||||||
|
aria-label="清除"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<label className="text-xs font-medium text-slate-400">分类</label>
|
||||||
|
{query.category_id && (
|
||||||
|
<button
|
||||||
|
className="text-[11px] text-slate-500 hover:text-white"
|
||||||
|
onClick={() => patch({ category_id: undefined })}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{cats.data?.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
className={`chip ${query.category_id === c.id ? "chip-active" : ""}`}
|
||||||
|
onClick={() =>
|
||||||
|
patch({ category_id: query.category_id === c.id ? undefined : c.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ background: c.color ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-slate-400">时间区间</label>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input"
|
||||||
|
value={query.date_from ? query.date_from.slice(0, 10) : ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ date_from: e.target.value ? `${e.target.value}T00:00:00` : undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input"
|
||||||
|
value={query.date_to ? query.date_to.slice(0, 10) : ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ date_to: e.target.value ? `${e.target.value}T23:59:59` : undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-slate-400">排序</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={query.sort ?? "captured_desc"}
|
||||||
|
onChange={(e) => patch({ sort: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="captured_desc">截取时间(新→旧)</option>
|
||||||
|
<option value="captured_asc">截取时间(旧→新)</option>
|
||||||
|
<option value="imported_desc">导入时间(最新)</option>
|
||||||
|
<option value="imported_asc">导入时间(最早)</option>
|
||||||
|
<option value="title_asc">标题 A→Z</option>
|
||||||
|
<option value="title_desc">标题 Z→A</option>
|
||||||
|
<option value="size_desc">文件大小(大→小)</option>
|
||||||
|
<option value="size_asc">文件大小(小→大)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs font-medium text-slate-400">状态</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["", "done", "pending", "running", "failed"].map((st) => (
|
||||||
|
<button
|
||||||
|
key={st || "all"}
|
||||||
|
className={`chip ${
|
||||||
|
(query.status ?? "") === st ? "chip-active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => patch({ status: st || undefined })}
|
||||||
|
>
|
||||||
|
{st === ""
|
||||||
|
? "全部"
|
||||||
|
: st === "done"
|
||||||
|
? "已分析"
|
||||||
|
: st === "pending"
|
||||||
|
? "排队"
|
||||||
|
: st === "running"
|
||||||
|
? "分析中"
|
||||||
|
: "失败"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${query.favorite ? "btn-primary" : ""}`}
|
||||||
|
onClick={() => patch({ favorite: query.favorite ? undefined : true })}
|
||||||
|
>
|
||||||
|
<Star size={14} /> 仅看收藏
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{tags.data && tags.data.items.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<label className="text-xs font-medium text-slate-400">热门标签</label>
|
||||||
|
<Link to="/tags" className="text-[11px] text-brand-400 hover:underline">
|
||||||
|
全部 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{tags.data.items.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`chip ${query.tag === t.name ? "chip-active" : ""}`}
|
||||||
|
onClick={() => patch({ tag: query.tag === t.name ? undefined : t.name })}
|
||||||
|
>
|
||||||
|
#{t.name}
|
||||||
|
<span className="opacity-50">{t.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
interface Props {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
pending: "等待中",
|
||||||
|
running: "分析中",
|
||||||
|
done: "完成",
|
||||||
|
failed: "失败",
|
||||||
|
skipped: "跳过",
|
||||||
|
};
|
||||||
|
|
||||||
|
const styleMap: Record<string, string> = {
|
||||||
|
pending: "bg-slate-700/60 text-slate-300",
|
||||||
|
running: "bg-amber-500/20 text-amber-300",
|
||||||
|
done: "bg-emerald-500/20 text-emerald-300",
|
||||||
|
failed: "bg-rose-500/20 text-rose-300",
|
||||||
|
skipped: "bg-slate-700/40 text-slate-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: Props) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] ${
|
||||||
|
styleMap[status] ?? styleMap.pending
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{labelMap[status] ?? status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(99, 102, 241, 0.6);
|
||||||
|
box-shadow: 0 12px 32px -16px rgba(99, 102, 241, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800/60 px-3 py-1.5 text-sm text-slate-200 transition hover:border-brand-500 hover:bg-slate-800 hover:text-white;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply border-brand-500 bg-brand-600 text-white hover:bg-brand-500;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply border-transparent bg-transparent text-slate-400 hover:text-white;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-md border border-slate-700 bg-slate-900/70 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-brand-500 focus:outline-none;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
@apply inline-flex items-center gap-1 rounded-full border border-slate-700 bg-slate-800/60 px-2.5 py-0.5 text-xs text-slate-300;
|
||||||
|
}
|
||||||
|
.chip-active {
|
||||||
|
@apply border-brand-500 bg-brand-600/20 text-white;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 15_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Sparkles, Images, ListChecks, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { DetailPanel } from "@/components/DetailPanel";
|
||||||
|
import { Card } from "@/components/Card";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const stats = useQuery({ queryKey: ["stats"], queryFn: api.stats });
|
||||||
|
const daily = useQuery({
|
||||||
|
queryKey: ["random", 6],
|
||||||
|
queryFn: () => api.randomScreenshots({ n: 6 }),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const todoSummary = useQuery({ queryKey: ["todo-summary"], queryFn: api.todoSummary });
|
||||||
|
const queue = useQuery({
|
||||||
|
queryKey: ["queue"],
|
||||||
|
queryFn: api.queueStatus,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const [openId, setOpenId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto px-8 py-6">
|
||||||
|
<header className="mb-6 flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||||
|
<Sparkles size={22} className="text-brand-400" />
|
||||||
|
欢迎回来
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
让 AI 帮你重新认识这些截图
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<Link to="/library" className="btn btn-primary">
|
||||||
|
<Images size={14} /> 进入截图库
|
||||||
|
</Link>
|
||||||
|
<Link to="/todos" className="btn">
|
||||||
|
<ListChecks size={14} /> 查看待办
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mb-6 grid grid-cols-4 gap-4">
|
||||||
|
<StatCard label="截图总数" value={stats.data?.total ?? 0} />
|
||||||
|
<StatCard
|
||||||
|
label="已分析"
|
||||||
|
value={stats.data?.by_status?.done ?? 0}
|
||||||
|
hint={`失败 ${stats.data?.by_status?.failed ?? 0} · 排队 ${stats.data?.by_status?.pending ?? 0}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="待办"
|
||||||
|
value={todoSummary.data?.pending ?? 0}
|
||||||
|
hint={`已完成 ${todoSummary.data?.done ?? 0}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="队列"
|
||||||
|
value={queue.data?.pending ?? 0}
|
||||||
|
hint={`运行中 ${queue.data?.running ?? 0} · 失败 ${queue.data?.failed ?? 0}`}
|
||||||
|
icon={(queue.data?.running ?? 0) > 0 ? <Loader2 className="animate-spin" size={14} /> : undefined}
|
||||||
|
to="/queue"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="mb-3 flex items-end justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-white">每日回顾</h2>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => daily.refetch()}
|
||||||
|
disabled={daily.isFetching}
|
||||||
|
>
|
||||||
|
换一批
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{(daily.data ?? []).map((s) => (
|
||||||
|
<Card key={s.id} shot={s} onOpen={setOpenId} />
|
||||||
|
))}
|
||||||
|
{(daily.data?.length ?? 0) === 0 && !daily.isLoading && (
|
||||||
|
<div className="col-span-full rounded-md border border-dashed border-slate-700 p-8 text-center text-sm text-slate-500">
|
||||||
|
库里还没有截图,先去
|
||||||
|
<Link to="/settings" className="mx-1 text-brand-400 underline">
|
||||||
|
设置
|
||||||
|
</Link>
|
||||||
|
里添加一个监听目录吧。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{stats.data && stats.data.by_category.length > 0 && (
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-white">分类分布</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
{stats.data.by_category
|
||||||
|
.filter((c) => c.id)
|
||||||
|
.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.id}
|
||||||
|
to={`/library`}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-slate-800 bg-slate-900/50 px-4 py-3 transition hover:border-brand-500"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm text-slate-200">
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ background: c.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-slate-100">{c.count}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
icon,
|
||||||
|
to,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
hint?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
to?: string;
|
||||||
|
}) {
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-slate-500">{label}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-2xl font-semibold text-white">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
{hint && <div className="mt-1 text-xs text-slate-500">{hint}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="block rounded-xl border border-slate-800 bg-slate-900/50 px-5 py-4 transition hover:border-brand-500"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/50 px-5 py-4">
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { CardGrid } from "@/components/CardGrid";
|
||||||
|
import { DetailPanel } from "@/components/DetailPanel";
|
||||||
|
import { FilterBar } from "@/components/FilterBar";
|
||||||
|
import type { ListQuery, ScreenshotBrief } from "@/types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 80;
|
||||||
|
|
||||||
|
function initialQuery(params: URLSearchParams): ListQuery {
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
tag: params.get("tag") ?? undefined,
|
||||||
|
q: params.get("q") ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LibraryPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [query, setQuery] = useState<ListQuery>(() => initialQuery(searchParams));
|
||||||
|
const [openId, setOpenId] = useState<number | null>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const listKey = useMemo(() => ["screenshots", query], [query]);
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: listKey,
|
||||||
|
queryFn: () => api.listScreenshots({ ...query, size: PAGE_SIZE }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
|
const toggleFav = useMutation({
|
||||||
|
mutationFn: (shot: ScreenshotBrief) =>
|
||||||
|
api.updateScreenshot(shot.id, { is_favorite: !shot.is_favorite }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["screenshots"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
<FilterBar query={query} onChange={setQuery} />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-white">截图库</h1>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
共 {list.data?.total ?? 0} 张
|
||||||
|
{list.isFetching && <span className="ml-2 text-brand-400">加载中…</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
disabled={(query.page ?? 1) <= 1}
|
||||||
|
onClick={() => setQuery((q) => ({ ...q, page: Math.max(1, (q.page ?? 1) - 1) }))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
第 {query.page ?? 1} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
disabled={(query.page ?? 1) >= totalPages}
|
||||||
|
onClick={() =>
|
||||||
|
setQuery((q) => ({ ...q, page: Math.min(totalPages, (q.page ?? 1) + 1) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 overflow-hidden p-4">
|
||||||
|
<CardGrid
|
||||||
|
items={list.data?.items ?? []}
|
||||||
|
onOpen={setOpenId}
|
||||||
|
onToggleFav={(shot) => toggleFav.mutate(shot)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,673 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
|
||||||
|
ListOrdered,
|
||||||
|
|
||||||
|
Loader2,
|
||||||
|
|
||||||
|
RefreshCw,
|
||||||
|
|
||||||
|
RotateCcw,
|
||||||
|
|
||||||
|
AlertCircle,
|
||||||
|
|
||||||
|
Image as ImageIcon,
|
||||||
|
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
|
||||||
|
import { DetailPanel } from "@/components/DetailPanel";
|
||||||
|
|
||||||
|
import type { JobItem } from "@/types";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const STATUS_TABS: { key: string; label: string }[] = [
|
||||||
|
|
||||||
|
{ key: "failed", label: "失败" },
|
||||||
|
|
||||||
|
{ key: "running", label: "运行中" },
|
||||||
|
|
||||||
|
{ key: "pending", label: "排队中" },
|
||||||
|
|
||||||
|
{ key: "done", label: "已完成" },
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** 从路径取文件名,便于列表展示。 */
|
||||||
|
|
||||||
|
function basename(path?: string | null) {
|
||||||
|
|
||||||
|
if (!path) return "—";
|
||||||
|
|
||||||
|
const parts = path.replace(/\\/g, "/").split("/");
|
||||||
|
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default function QueuePage() {
|
||||||
|
|
||||||
|
const [status, setStatus] = useState("failed");
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const [openId, setOpenId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queue = useQuery({
|
||||||
|
|
||||||
|
queryKey: ["queue"],
|
||||||
|
|
||||||
|
queryFn: api.queueStatus,
|
||||||
|
|
||||||
|
refetchInterval: 5000,
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
|
||||||
|
queryKey: ["jobs", status, page],
|
||||||
|
|
||||||
|
queryFn: () => api.listJobs({ status, page, size: PAGE_SIZE }),
|
||||||
|
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const retryFailed = useMutation({
|
||||||
|
|
||||||
|
mutationFn: (jobIds?: number[]) => api.retryFailedJobs(jobIds),
|
||||||
|
|
||||||
|
onSuccess: () => {
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: ["jobs"] });
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: ["queue"] });
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: ["stats"] });
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const resetStale = useMutation({
|
||||||
|
|
||||||
|
mutationFn: (all?: boolean) => api.resetStaleJobs(all),
|
||||||
|
|
||||||
|
onSuccess: () => {
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: ["jobs"] });
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: ["queue"] });
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const enqueueOcr = useMutation({
|
||||||
|
|
||||||
|
mutationFn: () => api.enqueueOcrFailed(500),
|
||||||
|
|
||||||
|
onSuccess: () => {
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: ["jobs"] });
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: ["queue"] });
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const onTabChange = (key: string) => {
|
||||||
|
|
||||||
|
setStatus(key);
|
||||||
|
|
||||||
|
setPage(1);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className="flex h-full flex-col px-8 py-6">
|
||||||
|
|
||||||
|
<header className="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||||
|
|
||||||
|
<ListOrdered size={22} className="text-brand-400" />
|
||||||
|
|
||||||
|
分析队列
|
||||||
|
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
|
||||||
|
查看失败原因、OCR 补跑、复位僵尸任务
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
{(queue.data?.ocr_retryable ?? 0) > 0 && (
|
||||||
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
className="btn border-brand-600/50 text-brand-300"
|
||||||
|
|
||||||
|
disabled={enqueueOcr.isPending}
|
||||||
|
|
||||||
|
onClick={() => enqueueOcr.mutate()}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<RefreshCw size={14} className={enqueueOcr.isPending ? "animate-spin" : ""} />
|
||||||
|
|
||||||
|
补跑 OCR 失败 ({queue.data?.ocr_retryable})
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(queue.data?.running ?? 0) > 0 && (queue.data?.inflight ?? 0) === 0 && (
|
||||||
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
className="btn border-amber-600/50 text-amber-300"
|
||||||
|
|
||||||
|
disabled={resetStale.isPending}
|
||||||
|
|
||||||
|
onClick={() => resetStale.mutate(true)}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
|
||||||
|
复位僵尸运行中 ({queue.data?.running})
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(queue.data?.failed ?? 0) > 0 && (
|
||||||
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
className="btn btn-primary"
|
||||||
|
|
||||||
|
disabled={retryFailed.isPending}
|
||||||
|
|
||||||
|
onClick={() => retryFailed.mutate(undefined)}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<RefreshCw size={14} className={retryFailed.isPending ? "animate-spin" : ""} />
|
||||||
|
|
||||||
|
重试全部失败 ({queue.data?.failed})
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<section className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||||
|
|
||||||
|
<QueueStat label="排队" value={queue.data?.pending ?? 0} />
|
||||||
|
|
||||||
|
<QueueStat
|
||||||
|
|
||||||
|
label="运行中"
|
||||||
|
|
||||||
|
value={queue.data?.running ?? 0}
|
||||||
|
|
||||||
|
hint={`实际并发 ${queue.data?.inflight ?? 0}`}
|
||||||
|
|
||||||
|
spinning={(queue.data?.inflight ?? 0) > 0}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QueueStat label="已完成" value={queue.data?.done ?? 0} />
|
||||||
|
|
||||||
|
<QueueStat label="失败" value={queue.data?.failed ?? 0} accent="text-red-400" />
|
||||||
|
|
||||||
|
<QueueStat
|
||||||
|
|
||||||
|
label="OCR 待补"
|
||||||
|
|
||||||
|
value={queue.data?.ocr_retryable ?? 0}
|
||||||
|
|
||||||
|
hint={`队列中 ${queue.data?.ocr_pending ?? 0}`}
|
||||||
|
|
||||||
|
accent="text-amber-300"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QueueStat label="Worker" value={queue.data?.inflight ?? 0} hint="inflight" />
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
|
||||||
|
{STATUS_TABS.map((t) => (
|
||||||
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
key={t.key}
|
||||||
|
|
||||||
|
className={`chip ${status === t.key ? "chip-active" : ""}`}
|
||||||
|
|
||||||
|
onClick={() => onTabChange(t.key)}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{t.label}
|
||||||
|
|
||||||
|
<span className="opacity-60">{queue.data?.[t.key as keyof typeof queue.data] ?? 0}</span>
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||||
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
className="btn"
|
||||||
|
|
||||||
|
disabled={page <= 1}
|
||||||
|
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
上一页
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
|
||||||
|
第 {page} / {totalPages} 页 · 共 {list.data?.total ?? 0} 条
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
className="btn"
|
||||||
|
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
下一页
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
|
||||||
|
{list.isLoading && (
|
||||||
|
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-slate-500">
|
||||||
|
|
||||||
|
<Loader2 className="mr-2 animate-spin" size={16} /> 加载中…
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{!list.isLoading && (list.data?.items.length ?? 0) === 0 && (
|
||||||
|
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-slate-500">
|
||||||
|
|
||||||
|
当前状态下没有任务
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<ul className="grid gap-3">
|
||||||
|
|
||||||
|
{(list.data?.items ?? []).map((job) => (
|
||||||
|
|
||||||
|
<JobRow
|
||||||
|
|
||||||
|
key={job.id}
|
||||||
|
|
||||||
|
job={job}
|
||||||
|
|
||||||
|
onOpen={() => setOpenId(job.screenshot_id)}
|
||||||
|
|
||||||
|
onRetry={() => retryFailed.mutate([job.id])}
|
||||||
|
|
||||||
|
retrying={retryFailed.isPending}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{status === "pending" && (list.data?.total ?? 0) > PAGE_SIZE && (
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-xs text-slate-500">
|
||||||
|
|
||||||
|
排队任务较多,已分页展示;Worker 会按 id 顺序依次处理。
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function QueueStat({
|
||||||
|
|
||||||
|
label,
|
||||||
|
|
||||||
|
value,
|
||||||
|
|
||||||
|
hint,
|
||||||
|
|
||||||
|
spinning,
|
||||||
|
|
||||||
|
accent,
|
||||||
|
|
||||||
|
}: {
|
||||||
|
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
hint?: string;
|
||||||
|
|
||||||
|
spinning?: boolean;
|
||||||
|
|
||||||
|
accent?: string;
|
||||||
|
|
||||||
|
}) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-900/50 px-4 py-3">
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-500">{label}</div>
|
||||||
|
|
||||||
|
<div className={`mt-1 flex items-center gap-2 text-xl font-semibold ${accent ?? "text-white"}`}>
|
||||||
|
|
||||||
|
{value.toLocaleString()}
|
||||||
|
|
||||||
|
{spinning && <Loader2 className="animate-spin text-brand-400" size={14} />}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hint && <div className="mt-0.5 text-[10px] text-slate-500">{hint}</div>}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function JobRow({
|
||||||
|
|
||||||
|
job,
|
||||||
|
|
||||||
|
onOpen,
|
||||||
|
|
||||||
|
onRetry,
|
||||||
|
|
||||||
|
retrying,
|
||||||
|
|
||||||
|
}: {
|
||||||
|
|
||||||
|
job: JobItem;
|
||||||
|
|
||||||
|
onOpen: () => void;
|
||||||
|
|
||||||
|
onRetry: () => void;
|
||||||
|
|
||||||
|
retrying: boolean;
|
||||||
|
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const statusColor =
|
||||||
|
|
||||||
|
job.status === "failed"
|
||||||
|
|
||||||
|
? "text-red-400"
|
||||||
|
|
||||||
|
: job.status === "running"
|
||||||
|
|
||||||
|
? "text-amber-400"
|
||||||
|
|
||||||
|
: job.status === "done"
|
||||||
|
|
||||||
|
? "text-emerald-400"
|
||||||
|
|
||||||
|
: "text-slate-400";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<li className="rounded-lg border border-slate-800 bg-slate-900/50 p-4">
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
type="button"
|
||||||
|
|
||||||
|
className="h-16 w-24 shrink-0 overflow-hidden rounded-md border border-slate-700 bg-slate-950"
|
||||||
|
|
||||||
|
onClick={onOpen}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{job.thumb_url ? (
|
||||||
|
|
||||||
|
<img src={job.thumb_url} alt="" className="h-full w-full object-cover" />
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
|
<div className="flex h-full items-center justify-center text-slate-600">
|
||||||
|
|
||||||
|
<ImageIcon size={20} />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
|
||||||
|
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
|
||||||
|
<span className="font-mono text-slate-500">#{job.id}</span>
|
||||||
|
|
||||||
|
<span className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] uppercase text-slate-400">
|
||||||
|
|
||||||
|
{job.kind}
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={`font-medium ${statusColor}`}>{job.status}</span>
|
||||||
|
|
||||||
|
<span className="text-slate-500">重试 {job.retries}</span>
|
||||||
|
|
||||||
|
{job.ai_status && (
|
||||||
|
|
||||||
|
<span className="text-slate-500">AI {job.ai_status}</span>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.ocr_status && (
|
||||||
|
|
||||||
|
<span className={job.ocr_status === "failed" ? "text-red-400" : "text-slate-500"}>
|
||||||
|
|
||||||
|
OCR {job.ocr_status}
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="truncate text-sm font-medium text-slate-100">
|
||||||
|
|
||||||
|
{job.ai_title || basename(job.path)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="truncate text-xs text-slate-500" title={job.path ?? undefined}>
|
||||||
|
|
||||||
|
{job.path}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{job.last_error && (
|
||||||
|
|
||||||
|
<div className="mt-2 flex gap-2 rounded-md border border-red-900/50 bg-red-950/30 px-3 py-2 text-xs text-red-200">
|
||||||
|
|
||||||
|
<AlertCircle size={14} className="mt-0.5 shrink-0 text-red-400" />
|
||||||
|
|
||||||
|
<pre className="whitespace-pre-wrap break-all font-mono leading-relaxed">
|
||||||
|
|
||||||
|
{job.last_error}
|
||||||
|
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-[10px] text-slate-500">
|
||||||
|
|
||||||
|
{job.created_at && <span>创建 {fmtTime(job.created_at)}</span>}
|
||||||
|
|
||||||
|
{job.started_at && <span>开始 {fmtTime(job.started_at)}</span>}
|
||||||
|
|
||||||
|
{job.finished_at && <span>结束 {fmtTime(job.finished_at)}</span>}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col gap-2">
|
||||||
|
|
||||||
|
<button className="btn" onClick={onOpen}>
|
||||||
|
|
||||||
|
详情
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{job.status === "failed" && (
|
||||||
|
|
||||||
|
<button className="btn" disabled={retrying} onClick={onRetry}>
|
||||||
|
|
||||||
|
重试
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function fmtTime(iso: string) {
|
||||||
|
|
||||||
|
return new Date(iso).toLocaleString("zh-CN", { hour12: false });
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FolderPlus, FolderOpen, RefreshCw, ShieldAlert, Trash2, Plug } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import type {
|
||||||
|
Category,
|
||||||
|
ProviderConfig,
|
||||||
|
ProviderConfigOut,
|
||||||
|
ProviderTestResult,
|
||||||
|
RecognitionMode,
|
||||||
|
WatchFolder,
|
||||||
|
} from "@/types";
|
||||||
|
import { RECOGNITION_MODE_LABELS } from "@/types";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto px-8 py-6">
|
||||||
|
<h1 className="mb-6 text-2xl font-semibold text-white">设置</h1>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<WatchFolderSection />
|
||||||
|
<RecognitionModeSection />
|
||||||
|
<ProviderSection
|
||||||
|
k="ocr_provider"
|
||||||
|
title="OCR 引擎"
|
||||||
|
desc="传统文字识别。在「传统 OCR / 混合」模式下使用;也可单独选「视觉模型识文」作为 OCR 引擎。"
|
||||||
|
defaults={{ type: "tesseract", extra: { lang: "chi_sim+eng" } }}
|
||||||
|
/>
|
||||||
|
<ProviderSection
|
||||||
|
k="vlm_provider"
|
||||||
|
title="视觉 AI 模型"
|
||||||
|
desc="多模态大模型:在「视觉 AI / 混合」模式下负责识文与分类摘要;支持 Ollama / GLM / MiniMax / OpenAI 等 OpenAI 兼容接口。"
|
||||||
|
defaults={{
|
||||||
|
type: "openai_compat",
|
||||||
|
base_url: "http://localhost:11434/v1",
|
||||||
|
model: "qwen2.5vl:7b",
|
||||||
|
extra: {},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CategorySection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecognitionModeSection() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const cur = useQuery({
|
||||||
|
queryKey: ["recognition-mode"],
|
||||||
|
queryFn: api.getRecognitionMode,
|
||||||
|
});
|
||||||
|
const [mode, setMode] = useState<RecognitionMode>("hybrid");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cur.data?.mode && cur.data.mode in RECOGNITION_MODE_LABELS) {
|
||||||
|
setMode(cur.data.mode as RecognitionMode);
|
||||||
|
}
|
||||||
|
}, [cur.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api.setRecognitionMode(mode),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["recognition-mode"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-5">
|
||||||
|
<header className="mb-1 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-white">文字识别方式</h2>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
disabled={save.isPending}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<p className="mb-4 text-xs text-slate-500">
|
||||||
|
选择截图文字如何被提取:纯 OCR、纯视觉 AI,或两者结合(OCR 文本供 AI 参考,AI 同时看图)。
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(Object.keys(RECOGNITION_MODE_LABELS) as RecognitionMode[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
className={`chip ${mode === m ? "chip-active" : ""}`}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
>
|
||||||
|
{RECOGNITION_MODE_LABELS[m]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-slate-500">
|
||||||
|
{mode === "ocr" && "仅使用下方 OCR 引擎提取文字;视觉 AI 仍可用于分类/摘要(若已配置)。"}
|
||||||
|
{mode === "vision" && "使用下方「视觉 AI 模型」从图片识文,不走 Tesseract 等传统 OCR。"}
|
||||||
|
{mode === "hybrid" && "先用 OCR 引擎识文,再交给视觉 AI 联合分析;OCR 失败时会自动尝试视觉识文。"}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WatchFolderSection() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const folders = useQuery({ queryKey: ["watch-folders"], queryFn: api.listWatchFolders });
|
||||||
|
const [draft, setDraft] = useState<Omit<WatchFolder, "id">>({
|
||||||
|
path: "",
|
||||||
|
enabled: true,
|
||||||
|
recursive: true,
|
||||||
|
is_sensitive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: () => api.addWatchFolder(draft),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["watch-folders"] });
|
||||||
|
setDraft({ ...draft, path: "" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: Omit<WatchFolder, "id"> }) =>
|
||||||
|
api.updateWatchFolder(id, payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["watch-folders"] }),
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteWatchFolder(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["watch-folders"] }),
|
||||||
|
});
|
||||||
|
const reimport = useMutation({
|
||||||
|
mutationFn: (folder: WatchFolder) =>
|
||||||
|
api.importNow({
|
||||||
|
path: folder.path,
|
||||||
|
enabled: folder.enabled,
|
||||||
|
recursive: folder.recursive,
|
||||||
|
is_sensitive: folder.is_sensitive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const [pathTestMsg, setPathTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const testPath = useMutation({
|
||||||
|
mutationFn: () => api.validateWatchPath(draft.path),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setPathTestMsg({
|
||||||
|
ok: true,
|
||||||
|
text: `${data.message}(规范化路径: ${data.path})`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setPathTestMsg({ ok: false, text: err.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-5">
|
||||||
|
<header className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-white">监听目录</h2>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
支持本地路径、UNC 网络路径(如 \\NAS\\共享\\文件夹)
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
className="input min-w-[280px] flex-1"
|
||||||
|
placeholder="D:/Pictures 或 \\JIULUGNAS\\personal_folder\\Photos\\..."
|
||||||
|
value={draft.path}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft({ ...draft, path: e.target.value });
|
||||||
|
setPathTestMsg(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-slate-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.recursive}
|
||||||
|
onChange={(e) => setDraft({ ...draft, recursive: e.target.checked })}
|
||||||
|
/>
|
||||||
|
递归
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-slate-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.is_sensitive}
|
||||||
|
onChange={(e) => setDraft({ ...draft, is_sensitive: e.target.checked })}
|
||||||
|
/>
|
||||||
|
敏感目录(禁止上传云端)
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
disabled={!draft.path || testPath.isPending}
|
||||||
|
onClick={() => testPath.mutate()}
|
||||||
|
>
|
||||||
|
<Plug size={14} /> 测试路径
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!draft.path || add.isPending}
|
||||||
|
onClick={() => add.mutate()}
|
||||||
|
>
|
||||||
|
<FolderPlus size={14} /> 添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{pathTestMsg && (
|
||||||
|
<p
|
||||||
|
className={`mb-3 text-xs ${pathTestMsg.ok ? "text-emerald-400" : "text-rose-400"}`}
|
||||||
|
>
|
||||||
|
{pathTestMsg.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="flex flex-col divide-y divide-slate-800">
|
||||||
|
{(folders.data ?? []).map((f) => (
|
||||||
|
<li key={f.id} className="flex items-center gap-2 py-2 text-sm">
|
||||||
|
<FolderOpen size={14} className="text-slate-500" />
|
||||||
|
<span className="flex-1 break-all text-slate-200">{f.path}</span>
|
||||||
|
{f.is_sensitive && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-amber-500/20 px-2 py-0.5 text-[10px] text-amber-300">
|
||||||
|
<ShieldAlert size={10} /> 敏感
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-1 text-xs text-slate-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={f.enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
update.mutate({
|
||||||
|
id: f.id,
|
||||||
|
payload: { ...f, enabled: e.target.checked },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
启用
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-slate-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={f.recursive}
|
||||||
|
onChange={(e) =>
|
||||||
|
update.mutate({
|
||||||
|
id: f.id,
|
||||||
|
payload: { ...f, recursive: e.target.checked },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
递归
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => reimport.mutate(f)}
|
||||||
|
disabled={reimport.isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} /> 重扫
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn text-rose-300 hover:border-rose-500"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`删除监听目录 ${f.path}?`)) remove.mutate(f.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{(folders.data?.length ?? 0) === 0 && (
|
||||||
|
<li className="py-4 text-center text-xs text-slate-500">
|
||||||
|
尚未添加监听目录
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderSection({
|
||||||
|
k,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
defaults,
|
||||||
|
}: {
|
||||||
|
k: "ocr_provider" | "vlm_provider";
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
defaults: ProviderConfig;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const cur = useQuery({ queryKey: ["provider", k], queryFn: () => api.getProvider(k) });
|
||||||
|
const [draft, setDraft] = useState<ProviderConfig>(defaults);
|
||||||
|
const mask: string | null | undefined = (cur.data as ProviderConfigOut | null | undefined)
|
||||||
|
?.api_key_mask;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cur.data) {
|
||||||
|
// 仅同步用户可编辑字段,避免把 api_key_mask 之类的派生字段灌进去
|
||||||
|
const { type, base_url, api_key, model, extra } = cur.data;
|
||||||
|
setDraft({
|
||||||
|
...defaults,
|
||||||
|
type: type || defaults.type,
|
||||||
|
base_url: base_url ?? defaults.base_url ?? null,
|
||||||
|
api_key: api_key ?? "",
|
||||||
|
model: model ?? defaults.model ?? null,
|
||||||
|
extra: { ...defaults.extra, ...(extra ?? {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [cur.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api.setProvider(k, draft),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["provider", k] }),
|
||||||
|
});
|
||||||
|
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null);
|
||||||
|
const testConn = useMutation({
|
||||||
|
mutationFn: () => api.testProvider(k, draft),
|
||||||
|
onSuccess: (data) => setTestResult(data),
|
||||||
|
onError: (err: Error) =>
|
||||||
|
setTestResult({ ok: false, message: err.message, detail: null, latency_ms: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-5">
|
||||||
|
<header className="mb-1 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => testConn.mutate()}
|
||||||
|
disabled={testConn.isPending || draft.type === "none"}
|
||||||
|
>
|
||||||
|
<Plug size={14} /> {testConn.isPending ? "测试中…" : "测试连通性"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
disabled={save.isPending}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p className="mb-4 text-xs text-slate-500">{desc}</p>
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`mb-4 rounded-md border px-3 py-2 text-xs ${
|
||||||
|
testResult.ok
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
|
||||||
|
: "border-rose-500/40 bg-rose-500/10 text-rose-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{testResult.message}</div>
|
||||||
|
{testResult.detail && (
|
||||||
|
<div className="mt-1 opacity-80">{testResult.detail}</div>
|
||||||
|
)}
|
||||||
|
{testResult.latency_ms != null && (
|
||||||
|
<div className="mt-1 opacity-60">耗时 {testResult.latency_ms} ms</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Field label="Provider 类型">
|
||||||
|
{k === "ocr_provider" ? (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={draft.type}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft({ ...draft, type: e.target.value });
|
||||||
|
setTestResult(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="tesseract">Tesseract(本地)</option>
|
||||||
|
<option value="paddleocr">PaddleOCR(本地,需安装)</option>
|
||||||
|
<option value="http">HTTP API(自定义 OCR 服务)</option>
|
||||||
|
<option value="vision">视觉模型识文(OpenAI 兼容)</option>
|
||||||
|
<option value="none">不使用</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={draft.type}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft({ ...draft, type: e.target.value });
|
||||||
|
setTestResult(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="openai_compat">OpenAI 兼容(推荐)</option>
|
||||||
|
<option value="none">不使用</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{(k === "vlm_provider" ||
|
||||||
|
(k === "ocr_provider" &&
|
||||||
|
(draft.type === "vision" || draft.type === "http"))) && (
|
||||||
|
<VisionApiFields
|
||||||
|
draft={draft}
|
||||||
|
setDraft={setDraft}
|
||||||
|
mask={mask}
|
||||||
|
showModel={draft.type !== "http" || k === "vlm_provider"}
|
||||||
|
urlLabel={draft.type === "http" && k === "ocr_provider" ? "OCR API URL" : "Base URL"}
|
||||||
|
urlPlaceholder={
|
||||||
|
draft.type === "http" && k === "ocr_provider"
|
||||||
|
? "https://your-ocr-service/recognize"
|
||||||
|
: "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{k === "ocr_provider" && draft.type === "tesseract" && (
|
||||||
|
<>
|
||||||
|
<Field label="语言(lang)">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={String(draft.extra.lang ?? "chi_sim+eng")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
extra: { ...draft.extra, lang: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="tesseract 路径(可选)">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="C:/Program Files/Tesseract-OCR/tesseract.exe"
|
||||||
|
value={String(draft.extra.cmd ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
extra: { ...draft.extra, cmd: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{k === "ocr_provider" && draft.type === "paddleocr" && (
|
||||||
|
<Field label="语言(lang)">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="ch / en / ..."
|
||||||
|
value={String(draft.extra.lang ?? "ch")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
extra: { ...draft.extra, lang: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{k === "ocr_provider" && draft.type === "http" && (
|
||||||
|
<Field label="响应文本字段(text_path)">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="text 或 data.text"
|
||||||
|
value={String(draft.extra.text_path ?? "text")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
extra: { ...draft.extra, text_path: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视觉 / HTTP OCR 共用的 URL、Model、API Key 表单项 */
|
||||||
|
function VisionApiFields({
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
mask,
|
||||||
|
showModel = true,
|
||||||
|
urlLabel = "Base URL",
|
||||||
|
urlPlaceholder = "https://api.openai.com/v1",
|
||||||
|
}: {
|
||||||
|
draft: ProviderConfig;
|
||||||
|
setDraft: (v: ProviderConfig) => void;
|
||||||
|
mask?: string | null;
|
||||||
|
showModel?: boolean;
|
||||||
|
urlLabel?: string;
|
||||||
|
urlPlaceholder?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label={urlLabel}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={urlPlaceholder}
|
||||||
|
value={draft.base_url ?? ""}
|
||||||
|
onChange={(e) => setDraft({ ...draft, base_url: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{showModel && (
|
||||||
|
<Field label="Model">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="gpt-4o-mini / glm-4v-flash / qwen2.5vl:7b"
|
||||||
|
value={draft.model ?? ""}
|
||||||
|
onChange={(e) => setDraft({ ...draft, model: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="API Key(留空则保留原值)">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
placeholder={mask ? `已配置:${mask}` : "sk-..."}
|
||||||
|
value={draft.api_key ?? ""}
|
||||||
|
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategorySection() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const cats = useQuery({ queryKey: ["categories"], queryFn: api.listCategories });
|
||||||
|
const [draft, setDraft] = useState<Omit<Category, "id">>({
|
||||||
|
name: "",
|
||||||
|
color: "#6366f1",
|
||||||
|
prompt_hint: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () => api.createCategory(draft),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
setDraft({ name: "", color: "#6366f1", prompt_hint: "" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: Omit<Category, "id"> }) =>
|
||||||
|
api.updateCategory(id, payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteCategory(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-5">
|
||||||
|
<header className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-white">分类管理</h2>
|
||||||
|
<span className="text-xs text-slate-500">名称会作为提示词提供给 AI</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-[1fr_120px_2fr_auto] gap-2">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="分类名"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="color"
|
||||||
|
value={draft.color ?? "#6366f1"}
|
||||||
|
onChange={(e) => setDraft({ ...draft, color: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="提示词(可选)"
|
||||||
|
value={draft.prompt_hint ?? ""}
|
||||||
|
onChange={(e) => setDraft({ ...draft, prompt_hint: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!draft.name || create.isPending}
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
>
|
||||||
|
新增
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex flex-col divide-y divide-slate-800">
|
||||||
|
{(cats.data ?? []).map((c) => (
|
||||||
|
<CategoryRow
|
||||||
|
key={c.id}
|
||||||
|
cat={c}
|
||||||
|
onSave={(payload) => update.mutate({ id: c.id, payload })}
|
||||||
|
onDelete={() => {
|
||||||
|
if (confirm(`删除分类 ${c.name}?`)) remove.mutate(c.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryRow({
|
||||||
|
cat,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
cat: Category;
|
||||||
|
onSave: (p: Omit<Category, "id">) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<Omit<Category, "id">>({
|
||||||
|
name: cat.name,
|
||||||
|
color: cat.color ?? "#6366f1",
|
||||||
|
prompt_hint: cat.prompt_hint ?? "",
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft({
|
||||||
|
name: cat.name,
|
||||||
|
color: cat.color ?? "#6366f1",
|
||||||
|
prompt_hint: cat.prompt_hint ?? "",
|
||||||
|
});
|
||||||
|
}, [cat]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<li className="grid grid-cols-[1fr_120px_2fr_auto] gap-2 py-2">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="color"
|
||||||
|
value={draft.color ?? "#6366f1"}
|
||||||
|
onChange={(e) => setDraft({ ...draft, color: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.prompt_hint ?? ""}
|
||||||
|
onChange={(e) => setDraft({ ...draft, prompt_hint: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
onSave(draft);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={() => setEditing(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="grid grid-cols-[1fr_120px_2fr_auto] items-center gap-2 py-2 text-sm">
|
||||||
|
<span className="flex items-center gap-2 text-slate-100">
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ background: cat.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
|
{cat.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">{cat.color}</span>
|
||||||
|
<span className="text-xs text-slate-400">{cat.prompt_hint || "—"}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button className="btn" onClick={() => setEditing(true)}>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn text-rose-300 hover:border-rose-500"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-slate-400">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Shuffle, ExternalLink, Star } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { DetailPanel } from "@/components/DetailPanel";
|
||||||
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
|
||||||
|
export default function ShufflePage() {
|
||||||
|
const [openId, setOpenId] = useState<number | null>(null);
|
||||||
|
const random = useQuery({
|
||||||
|
queryKey: ["random-one"],
|
||||||
|
queryFn: () => api.randomScreenshots({ n: 1 }),
|
||||||
|
});
|
||||||
|
const shot = random.data?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col px-8 py-6">
|
||||||
|
<header className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||||
|
<Shuffle size={20} className="text-brand-400" />
|
||||||
|
随机展示
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500">在过往截图中挑一张看看,发现意外的回忆</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => random.refetch()}
|
||||||
|
disabled={random.isFetching}
|
||||||
|
>
|
||||||
|
<Shuffle size={14} /> 再来一张
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!shot ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-slate-500">
|
||||||
|
{random.isLoading ? "加载中…" : "暂无截图"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 gap-6 overflow-hidden">
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-xl border border-slate-800 bg-black/30 p-4">
|
||||||
|
<img
|
||||||
|
src={`/api/screenshots/${shot.id}/file`}
|
||||||
|
alt={shot.ai_title ?? "screenshot"}
|
||||||
|
className="max-h-full max-w-full rounded-md shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<aside className="w-[360px] shrink-0 overflow-y-auto rounded-xl border border-slate-800 bg-slate-900/50 px-5 py-5">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<StatusBadge status={shot.ai_status} />
|
||||||
|
{shot.category && (
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${shot.category.color ?? "#6366f1"}22`,
|
||||||
|
color: shot.category.color ?? "#a5b4fc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shot.category.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{shot.is_favorite && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-400/20 px-2 py-0.5 text-xs text-amber-300">
|
||||||
|
<Star size={12} /> 收藏
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-white">
|
||||||
|
{shot.ai_title || "(未生成标题)"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
{new Date(shot.captured_at).toLocaleString("zh-CN", { hour12: false })}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<button className="btn" onClick={() => setOpenId(shot.id)}>
|
||||||
|
查看详情
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
className="btn"
|
||||||
|
href={`/api/screenshots/${shot.id}/file`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} /> 原图
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{shot.tags.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||||
|
{shot.tags.map((t) => (
|
||||||
|
<span key={t.id} className="chip">
|
||||||
|
#{t.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Hash, Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 80;
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: "count_desc", label: "使用最多" },
|
||||||
|
{ value: "count_asc", label: "使用最少" },
|
||||||
|
{ value: "name_asc", label: "名称 A→Z" },
|
||||||
|
{ value: "name_desc", label: "名称 Z→A" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TagsPage() {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [sort, setSort] = useState("count_desc");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ["tags", search, sort, page],
|
||||||
|
queryFn: () => api.listTags({ q: search || undefined, sort, page, size: PAGE_SIZE }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
|
const onSearch = () => {
|
||||||
|
setSearch(q.trim());
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = useMemo(() => list.data?.items ?? [], [list.data?.items]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col px-8 py-6">
|
||||||
|
<header className="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||||
|
<Hash size={22} className="text-brand-400" />
|
||||||
|
全部标签
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
共 {list.data?.total ?? 0} 个标签 · 点击跳转到截图库筛选
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input w-56 pl-7"
|
||||||
|
placeholder="搜索标签名…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn" onClick={onSearch}>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
<select className="input w-36" value={sort} onChange={(e) => { setSort(e.target.value); setPage(1); }}>
|
||||||
|
{SORT_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-3 flex items-center justify-end gap-2 text-sm">
|
||||||
|
<button className="btn" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
第 {page} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{list.isLoading && (
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-slate-500">加载中…</div>
|
||||||
|
)}
|
||||||
|
{!list.isLoading && items.length === 0 && (
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-slate-500">暂无标签</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{items.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.id}
|
||||||
|
to={`/library?tag=${encodeURIComponent(t.name)}`}
|
||||||
|
className="chip hover:border-brand-500"
|
||||||
|
>
|
||||||
|
#{t.name}
|
||||||
|
<span className="opacity-50">{t.count}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ListChecks, Check, X, Image as ImageIcon, Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { DetailPanel } from "@/components/DetailPanel";
|
||||||
|
import type { TodoListQuery } from "@/types";
|
||||||
|
|
||||||
|
const STATUS_TABS: { key: string; label: string }[] = [
|
||||||
|
{ key: "pending", label: "待办" },
|
||||||
|
{ key: "doing", label: "进行中" },
|
||||||
|
{ key: "done", label: "已完成" },
|
||||||
|
{ key: "dropped", label: "已搁置" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
export default function TodosPage() {
|
||||||
|
const [status, setStatus] = useState<string>("pending");
|
||||||
|
const [qInput, setQInput] = useState("");
|
||||||
|
const [query, setQuery] = useState<TodoListQuery>({ page: 1, size: PAGE_SIZE });
|
||||||
|
const [openId, setOpenId] = useState<number | null>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const summary = useQuery({ queryKey: ["todo-summary"], queryFn: api.todoSummary });
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ["todos", status, query],
|
||||||
|
queryFn: () => api.listTodos({ ...query, status, size: PAGE_SIZE }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: Parameters<typeof api.updateTodo>[1] }) =>
|
||||||
|
api.updateTodo(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["todos"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["todo-summary"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onTabChange = (key: string) => {
|
||||||
|
setStatus(key);
|
||||||
|
setQuery((prev) => ({ ...prev, page: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = () => {
|
||||||
|
setQuery((prev) => ({ ...prev, q: qInput.trim() || undefined, page: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col px-8 py-6">
|
||||||
|
<header className="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold text-white">
|
||||||
|
<ListChecks size={22} className="text-brand-400" />
|
||||||
|
待办清单
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
AI 从截图中识别出的「待看 / 待读 / 想试试」内容 · 共 {list.data?.total ?? 0} 条
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input w-52 pl-7"
|
||||||
|
placeholder="搜索标题/备注…"
|
||||||
|
value={qInput}
|
||||||
|
onChange={(e) => setQInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn" onClick={onSearch}>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
{STATUS_TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
className={`chip ${status === t.key ? "chip-active" : ""}`}
|
||||||
|
onClick={() => onTabChange(t.key)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
<span className="opacity-60">{summary.data?.[t.key] ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
disabled={(query.page ?? 1) <= 1}
|
||||||
|
onClick={() => setQuery((prev) => ({ ...prev, page: Math.max(1, (prev.page ?? 1) - 1) }))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
第 {query.page ?? 1} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
disabled={(query.page ?? 1) >= totalPages}
|
||||||
|
onClick={() =>
|
||||||
|
setQuery((prev) => ({ ...prev, page: Math.min(totalPages, (prev.page ?? 1) + 1) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{(list.data?.items.length ?? 0) === 0 && !list.isLoading && (
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-slate-500">
|
||||||
|
暂无内容
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.isLoading && (
|
||||||
|
<div className="flex h-40 items-center justify-center text-sm text-slate-500">加载中…</div>
|
||||||
|
)}
|
||||||
|
<ul className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{(list.data?.items ?? []).map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.id}
|
||||||
|
className="group rounded-lg border border-slate-800 bg-slate-900/50 p-4 transition hover:border-brand-500"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-[10px] text-slate-500">
|
||||||
|
<span>{t.kind ?? "待办"}</span>
|
||||||
|
<span>{new Date(t.created_at).toLocaleString("zh-CN", { hour12: false })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-slate-100">{t.title}</div>
|
||||||
|
{t.note && (
|
||||||
|
<div className="mt-1 line-clamp-3 text-xs text-slate-400">{t.note}</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<button className="btn" onClick={() => setOpenId(t.screenshot_id)}>
|
||||||
|
<ImageIcon size={14} /> 看原图
|
||||||
|
</button>
|
||||||
|
{status !== "done" && (
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => update.mutate({ id: t.id, payload: { status: "done" } })}
|
||||||
|
>
|
||||||
|
<Check size={14} /> 完成
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status !== "dropped" && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => update.mutate({ id: t.id, payload: { status: "dropped" } })}
|
||||||
|
>
|
||||||
|
<X size={14} /> 搁置
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status !== "pending" && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => update.mutate({ id: t.id, payload: { status: "pending" } })}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<DetailPanel id={openId} onClose={() => setOpenId(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
prompt_hint?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenshotBrief {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
captured_at: string;
|
||||||
|
thumb_url?: string | null;
|
||||||
|
ai_title?: string | null;
|
||||||
|
ai_status: string;
|
||||||
|
ocr_status: string;
|
||||||
|
is_favorite: boolean;
|
||||||
|
category?: Category | null;
|
||||||
|
tags: Tag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
note?: string | null;
|
||||||
|
kind?: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
screenshot_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenshotDetail extends ScreenshotBrief {
|
||||||
|
file_url: string;
|
||||||
|
size: number;
|
||||||
|
ocr_text?: string | null;
|
||||||
|
ai_summary?: string | null;
|
||||||
|
ai_suggestion?: string | null;
|
||||||
|
todos: TodoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResp {
|
||||||
|
items: ScreenshotBrief[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchFolder {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
enabled: boolean;
|
||||||
|
recursive: boolean;
|
||||||
|
is_sensitive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderConfig {
|
||||||
|
type: string;
|
||||||
|
base_url?: string | null;
|
||||||
|
api_key?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
extra: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取 Provider 时后端会附带 api_key_mask,用于 UI 提示。 */
|
||||||
|
export interface ProviderConfigOut extends ProviderConfig {
|
||||||
|
api_key_mask?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
detail?: string | null;
|
||||||
|
latency_ms?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsResp {
|
||||||
|
total: number;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
by_category: { id: number; name: string; color?: string | null; count: number }[];
|
||||||
|
by_month: { month: string; count: number }[];
|
||||||
|
queue: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecognitionMode = "ocr" | "vision" | "hybrid";
|
||||||
|
|
||||||
|
export const RECOGNITION_MODE_LABELS: Record<RecognitionMode, string> = {
|
||||||
|
ocr: "传统 OCR",
|
||||||
|
vision: "视觉 AI 识文",
|
||||||
|
hybrid: "混合(OCR + 视觉 AI)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ListQuery {
|
||||||
|
q?: string;
|
||||||
|
category_id?: number;
|
||||||
|
tag?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
favorite?: boolean;
|
||||||
|
status?: string;
|
||||||
|
sort?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobItem {
|
||||||
|
id: number;
|
||||||
|
screenshot_id: number;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
retries: number;
|
||||||
|
last_error?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
started_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
thumb_url?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
ai_title?: string | null;
|
||||||
|
ai_status?: string | null;
|
||||||
|
ocr_status?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoListResp {
|
||||||
|
items: TodoItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagListResp {
|
||||||
|
items: (Tag & { count: number })[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoListQuery {
|
||||||
|
status?: string;
|
||||||
|
kind?: string;
|
||||||
|
q?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobListResp {
|
||||||
|
items: JobItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: "#eef2ff",
|
||||||
|
100: "#e0e7ff",
|
||||||
|
400: "#818cf8",
|
||||||
|
500: "#6366f1",
|
||||||
|
600: "#4f46e5",
|
||||||
|
700: "#4338ca",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:8765",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# snapAna 一键启动脚本(PowerShell)
|
||||||
|
# 同时启动后端 (uvicorn @ 8765) 与前端 (vite @ 5173)
|
||||||
|
# 使用方式:在仓库根目录运行 .\start-dev.ps1
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$InstallDeps # 传入此参数会重新安装依赖
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$backend = Join-Path $root "backend"
|
||||||
|
$frontend = Join-Path $root "frontend"
|
||||||
|
|
||||||
|
Write-Host "[snapAna] root = $root"
|
||||||
|
|
||||||
|
# 1. 后端虚拟环境
|
||||||
|
if (!(Test-Path (Join-Path $backend ".venv"))) {
|
||||||
|
Write-Host "[snapAna] 创建 Python 虚拟环境..."
|
||||||
|
& python -m venv (Join-Path $backend ".venv")
|
||||||
|
}
|
||||||
|
$venvPython = Join-Path $backend ".venv\Scripts\python.exe"
|
||||||
|
|
||||||
|
if ($InstallDeps -or !(Test-Path (Join-Path $backend ".venv\Lib\site-packages\fastapi"))) {
|
||||||
|
Write-Host "[snapAna] 安装后端依赖..."
|
||||||
|
& $venvPython -m pip install --upgrade pip
|
||||||
|
& $venvPython -m pip install -r (Join-Path $backend "requirements.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 前端依赖
|
||||||
|
if ($InstallDeps -or !(Test-Path (Join-Path $frontend "node_modules"))) {
|
||||||
|
Write-Host "[snapAna] 安装前端依赖..."
|
||||||
|
Push-Location $frontend
|
||||||
|
npm install
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. 后台启动后端
|
||||||
|
Write-Host "[snapAna] 启动后端 http://127.0.0.1:8765 ..."
|
||||||
|
$backendProc = Start-Process -FilePath $venvPython `
|
||||||
|
-ArgumentList "run.py" `
|
||||||
|
-WorkingDirectory $backend `
|
||||||
|
-PassThru `
|
||||||
|
-WindowStyle Normal
|
||||||
|
|
||||||
|
# 4. 启动前端(占用当前控制台,便于查看日志)
|
||||||
|
Write-Host "[snapAna] 启动前端 http://127.0.0.1:5173 ..."
|
||||||
|
Push-Location $frontend
|
||||||
|
try {
|
||||||
|
npm run dev
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
Write-Host "[snapAna] 关闭后端 (PID $($backendProc.Id))..."
|
||||||
|
Stop-Process -Id $backendProc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user