feat(read): 已读功能 — 每账号标已读,列表默认隐藏

需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。

设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段

前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
    顶部加 '隐藏已读' 开关,默认 ON
    每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
    已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
This commit is contained in:
xiaji
2026-06-13 21:04:47 +08:00
parent 8b3c7caf87
commit 6c71ab2e79
9 changed files with 352 additions and 4 deletions

View File

@@ -4,6 +4,7 @@
"""
from app.models.api_token import ApiToken # noqa: F401
from app.models.article import Article # noqa: F401
from app.models.article_read import ArticleRead # noqa: F401
from app.models.bookmark import Bookmark # noqa: F401
from app.models.llm_setting import LlmSetting # noqa: F401
from app.models.source import Source, SourceKind # noqa: F401
@@ -13,6 +14,7 @@ from app.models.user import User, UserRole # noqa: F401
__all__ = [
"ApiToken",
"Article",
"ArticleRead",
"Bookmark",
"LlmSetting",
"Source",

View File

@@ -0,0 +1,46 @@
"""文章已读记录(per-user)。
设计:
- 复合主键 (user_id, article_id) — 天然防重复,标记已读是幂等的
- read_at 默认 now(),用于排序(最近已读在前)
- 索引: (user_id, read_at DESC) — 用户查自己最近已读用
(article_id) — 反向查"谁读过这文章"(暂不用,留扩展)
注意:
- 删文章时 ondelete=CASCADE 自动清掉已读记录
- 这是和 bookmarks 并列的"用户行为"
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ArticleRead(Base):
__tablename__ = "article_reads"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
article_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("articles.id", ondelete="CASCADE"),
primary_key=True,
)
read_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
__table_args__ = (
Index("ix_article_reads_user_read_at", "user_id", "read_at"),
Index("ix_article_reads_article", "article_id"),
)
def __repr__(self) -> str:
return f"<ArticleRead user={self.user_id} article={self.article_id} at={self.read_at}>"