feat(feed): 列表展示翻译正文摘要 + 页码分页
首页 Feed.vue 改造: - 卡片在中文标题下直接展示 body_zh_text(前 220 字) 用户不进详情就能看到译文正文,提升阅读效率 - 配图(image_ai_url 或 image_url)也直接显示在卡片中 - 把原标题作为副标题(灰色,辅助参考) 分页从 cursor 无限滚动换成 page + page_size: - 后端 /articles 加 page/page_size 参数,返回 total/total_pages - 干掉 _encode_cursor/_decode_cursor - 前端用 n-pagination,显示 1,2,3,4,5 + 快速跳转 - 筛选/搜索变化自动回到第 1 页 - 切页自动滚到顶部
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
"""/articles 列表与详情。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
@@ -26,19 +24,6 @@ from app.schemas.article import (
|
||||
router = APIRouter(prefix="/articles", tags=["articles"])
|
||||
|
||||
|
||||
def _encode_cursor(article: Article) -> str:
|
||||
payload = {"id": article.id, "ts": int(article.fetched_at.timestamp())}
|
||||
return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
||||
|
||||
|
||||
def _decode_cursor(cur: str) -> tuple[int, datetime]:
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(cur.encode()).decode())
|
||||
return int(data["id"]), datetime.fromtimestamp(int(data["ts"]))
|
||||
except Exception:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid cursor")
|
||||
|
||||
|
||||
@router.get("", response_model=ArticleListResponse)
|
||||
async def list_articles(
|
||||
since: datetime | None = Query(default=None, description="起时间 UTC"),
|
||||
@@ -47,58 +32,70 @@ async def list_articles(
|
||||
category: str | None = None,
|
||||
q: str | None = Query(default=None, description="标题/正文搜索"),
|
||||
lang: Annotated[str, Query(pattern=r"^(src|zh|both)$")] = "both",
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
cursor: str | None = None,
|
||||
page: int = Query(default=1, ge=1, description="页码(从 1 开始)"),
|
||||
page_size: int = Query(default=50, ge=1, le=200, description="每页条数"),
|
||||
starred_only: bool = False,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
stmt = (
|
||||
select(Article, Source)
|
||||
.join(Source, Source.id == Article.source_id)
|
||||
.where(Article.duplicate_of.is_(None))
|
||||
)
|
||||
# 公共筛选条件(用于 list + count)
|
||||
filters = [Article.duplicate_of.is_(None)]
|
||||
|
||||
# 默认过去 24h
|
||||
if since is None and until is None and cursor is None:
|
||||
if since is None and until is None:
|
||||
since = _default_since_24h()
|
||||
|
||||
if since:
|
||||
stmt = stmt.where(Article.published_at >= since)
|
||||
filters.append(Article.published_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(Article.published_at <= until)
|
||||
filters.append(Article.published_at <= until)
|
||||
if category:
|
||||
stmt = stmt.where(Article.category == category)
|
||||
filters.append(Article.category == category)
|
||||
|
||||
if source:
|
||||
slugs = [s.strip() for s in source.split(",") if s.strip()]
|
||||
if slugs:
|
||||
stmt = stmt.where(Source.slug.in_(slugs))
|
||||
filters.append(Source.slug.in_(slugs))
|
||||
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where(or_(Article.title.ilike(like), Article.body_text.ilike(like)))
|
||||
filters.append(or_(Article.title.ilike(like), Article.body_text.ilike(like)))
|
||||
|
||||
# 语言过滤
|
||||
if lang == "zh":
|
||||
stmt = stmt.where(Article.title_zh.is_not(None))
|
||||
elif lang == "src":
|
||||
# 只要原文已有
|
||||
pass
|
||||
filters.append(Article.title_zh.is_not(None))
|
||||
|
||||
if cursor:
|
||||
last_id, _ = _decode_cursor(cursor)
|
||||
stmt = stmt.where(Article.id < last_id)
|
||||
# ===== count 总数 =====
|
||||
count_stmt = select(func.count(Article.id)).join(Source, Source.id == Article.source_id)
|
||||
for f in filters:
|
||||
count_stmt = count_stmt.where(f)
|
||||
if starred_only:
|
||||
count_stmt = count_stmt.join(
|
||||
Bookmark, and_(Bookmark.article_id == Article.id, Bookmark.user_id == user.id)
|
||||
)
|
||||
total: int = (await session.execute(count_stmt)).scalar_one()
|
||||
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
# 越界保护:page 超出范围时返回空数组,total 仍真实
|
||||
if page > total_pages:
|
||||
return ArticleListResponse(
|
||||
items=[], page=page, page_size=page_size, total=total, total_pages=total_pages
|
||||
)
|
||||
|
||||
# ===== 当前页数据 =====
|
||||
stmt = select(Article, Source).join(Source, Source.id == Article.source_id)
|
||||
for f in filters:
|
||||
stmt = stmt.where(f)
|
||||
if starred_only:
|
||||
stmt = stmt.join(Bookmark, and_(Bookmark.article_id == Article.id, Bookmark.user_id == user.id))
|
||||
|
||||
stmt = stmt.order_by(desc(Article.published_at), desc(Article.id)).limit(limit + 1)
|
||||
offset = (page - 1) * page_size
|
||||
stmt = (
|
||||
stmt.order_by(desc(Article.published_at), desc(Article.id))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
rows = result.all()
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
rows = (await session.execute(stmt)).all()
|
||||
|
||||
# 标记 is_starred(批量)
|
||||
ids = [a.id for a, _ in rows]
|
||||
@@ -115,28 +112,35 @@ async def list_articles(
|
||||
|
||||
items = []
|
||||
for art, src in rows:
|
||||
item = ArticleListItem(
|
||||
id=art.id,
|
||||
source=SourceBrief.model_validate(src),
|
||||
title=art.title,
|
||||
title_zh=art.title_zh,
|
||||
summary_zh=art.summary_zh,
|
||||
lang_src=art.lang_src,
|
||||
translation_status=art.translation_status,
|
||||
category=art.category,
|
||||
published_at=art.published_at,
|
||||
fetched_at=art.fetched_at,
|
||||
image_url=art.image_url,
|
||||
# 列表预览钩子:分类 + LLM 点评 + AI 插图 缩略图
|
||||
commentary=art.commentary,
|
||||
commentary_status=art.commentary_status,
|
||||
image_ai_url=art.image_ai_url,
|
||||
is_starred=art.id in starred_ids,
|
||||
items.append(
|
||||
ArticleListItem(
|
||||
id=art.id,
|
||||
source=SourceBrief.model_validate(src),
|
||||
title=art.title,
|
||||
title_zh=art.title_zh,
|
||||
body_zh_text=art.body_zh_text,
|
||||
summary_zh=art.summary_zh,
|
||||
lang_src=art.lang_src,
|
||||
translation_status=art.translation_status,
|
||||
category=art.category,
|
||||
published_at=art.published_at,
|
||||
fetched_at=art.fetched_at,
|
||||
image_url=art.image_url,
|
||||
# 列表预览钩子:分类 + LLM 点评 + AI 插图 缩略图
|
||||
commentary=art.commentary,
|
||||
commentary_status=art.commentary_status,
|
||||
image_ai_url=art.image_ai_url,
|
||||
is_starred=art.id in starred_ids,
|
||||
)
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
next_cursor = _encode_cursor(rows[-1][0]) if has_more and rows else None
|
||||
return ArticleListResponse(items=items, next_cursor=next_cursor, total=None)
|
||||
return ArticleListResponse(
|
||||
items=items,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{article_id}", response_model=ArticleDetail)
|
||||
|
||||
@@ -16,7 +16,7 @@ class SourceBrief(BaseModel):
|
||||
|
||||
|
||||
class ArticleListItem(BaseModel):
|
||||
"""列表项:精简字段(首页只露钩子,详细阅读进详情页)。"""
|
||||
"""列表项:首页展示标题/译标/正文摘要/分类/插图,详细阅读进详情页。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -24,6 +24,8 @@ class ArticleListItem(BaseModel):
|
||||
source: SourceBrief
|
||||
title: str
|
||||
title_zh: str | None = None
|
||||
# 翻译后的正文(纯文本);列表里截断显示,详情页展示完整
|
||||
body_zh_text: str | None = None
|
||||
summary_zh: str | None = None
|
||||
lang_src: str | None = None
|
||||
translation_status: str
|
||||
@@ -76,8 +78,11 @@ class ArticleDetail(BaseModel):
|
||||
|
||||
class ArticleListResponse(BaseModel):
|
||||
items: list[ArticleListItem]
|
||||
next_cursor: str | None = None
|
||||
total: int | None = None
|
||||
# 页码分页
|
||||
page: int = 1
|
||||
page_size: int = 50
|
||||
total: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class ArticleQuery(BaseModel):
|
||||
@@ -89,6 +94,6 @@ class ArticleQuery(BaseModel):
|
||||
category: str | None = None
|
||||
q: str | None = None
|
||||
lang: str = Field(default="both", pattern=r"^(src|zh|both)$")
|
||||
limit: int = Field(default=50, ge=1, le=200)
|
||||
cursor: str | None = None
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=50, ge=1, le=200)
|
||||
starred_only: bool = False
|
||||
|
||||
Reference in New Issue
Block a user