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

@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user
from app.database import get_session
from app.models.article import Article
from app.models.article_read import ArticleRead
from app.models.bookmark import Bookmark
from app.models.source import Source
from app.models.user import User
@@ -35,6 +36,7 @@ async def list_articles(
page: int = Query(default=1, ge=1, description="页码(从 1 开始)"),
page_size: int = Query(default=50, ge=1, le=200, description="每页条数"),
starred_only: bool = False,
hide_read: bool = Query(default=True, description="是否隐藏已读文章(默认 true)"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
@@ -64,6 +66,18 @@ async def list_articles(
if lang == "zh":
filters.append(Article.title_zh.is_not(None))
if hide_read:
# LEFT JOIN article_reads,WHERE 排除已读
# 注意:这里用 NOT EXISTS 子查询更干净(避免影响 count/select 的 JOIN 链)
from sqlalchemy import not_, exists
filters.append(
not_(
select(ArticleRead.article_id)
.where(ArticleRead.user_id == user.id, ArticleRead.article_id == Article.id)
.exists()
)
)
# ===== count 总数 =====
count_stmt = select(func.count(Article.id)).join(Source, Source.id == Article.source_id)
for f in filters:
@@ -97,9 +111,10 @@ async def list_articles(
rows = (await session.execute(stmt)).all()
# 标记 is_starred(批量)
# 标记 is_starred(批量)+ is_read(批量)
ids = [a.id for a, _ in rows]
starred_ids: set[int] = set()
read_ids: set[int] = set()
if ids:
bm_rows = (
await session.execute(
@@ -109,6 +124,15 @@ async def list_articles(
)
).all()
starred_ids = {b[0] for b in bm_rows}
# 查 is_read(无论 hide_read 都查,前端可拿"已读"标识,例如"显示已读"开关打开时仍要知道哪些是已读的)
rd_rows = (
await session.execute(
select(ArticleRead.article_id).where(
ArticleRead.user_id == user.id, ArticleRead.article_id.in_(ids)
)
)
).all()
read_ids = {r[0] for r in rd_rows}
items = []
for art, src in rows:
@@ -134,6 +158,7 @@ async def list_articles(
commentary_engine=art.commentary_engine,
image_ai_url=art.image_ai_url,
is_starred=art.id in starred_ids,
is_read=art.id in read_ids,
)
)
@@ -170,6 +195,14 @@ async def get_article(
)
).first() is not None
is_read = (
await session.execute(
select(ArticleRead.article_id).where(
ArticleRead.user_id == user.id, ArticleRead.article_id == article.id
)
)
).first() is not None
return ArticleDetail(
id=article.id,
source=SourceBrief.model_validate(source),
@@ -207,6 +240,7 @@ async def get_article(
published_at=article.published_at,
fetched_at=article.fetched_at,
is_starred=is_starred,
is_read=is_read,
)