Initial commit: snapAna 截图智能整理工具

包含 FastAPI 后端、React 前端、队列/OCR/标签/待办等完整功能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wjl
2026-05-27 15:45:50 +08:00
commit 5c028d7952
76 changed files with 10467 additions and 0 deletions
+13
View File
@@ -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
+32
View File
@@ -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": "无法明确归类"},
]
+54
View File
@@ -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)
+27
View File
@@ -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")
+86
View File
@@ -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",
)
+26
View File
@@ -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"
+42
View File
@@ -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",
)
+47
View File
@@ -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")
+25
View File
@@ -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
)