diff --git a/backend/app/api/articles.py b/backend/app/api/articles.py index a6d0782..67f65ac 100644 --- a/backend/app/api/articles.py +++ b/backend/app/api/articles.py @@ -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) diff --git a/backend/app/schemas/article.py b/backend/app/schemas/article.py index 339d2b8..122549e 100644 --- a/backend/app/schemas/article.py +++ b/backend/app/schemas/article.py @@ -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 diff --git a/frontend/src/api/articles.ts b/frontend/src/api/articles.ts index 10bf8e9..a6aeaf2 100644 --- a/frontend/src/api/articles.ts +++ b/frontend/src/api/articles.ts @@ -24,6 +24,8 @@ export interface ArticleListItem { source: { id: number; name: string; slug: string; region?: string | null } title: string title_zh?: string | null + // 翻译后的正文(纯文本);列表里截断显示 + body_zh_text?: string | null summary_zh?: string | null lang_src?: string | null translation_status: string @@ -40,8 +42,10 @@ export interface ArticleListItem { export interface ArticleListResponse { items: ArticleListItem[] - next_cursor: string | null - total: number | null + page: number + page_size: number + total: number + total_pages: number } export interface ArticleDetail extends ArticleListItem { diff --git a/frontend/src/views/Feed.vue b/frontend/src/views/Feed.vue index a67cec3..305688b 100644 --- a/frontend/src/views/Feed.vue +++ b/frontend/src/views/Feed.vue @@ -1,8 +1,9 @@