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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user