diff --git a/backend/src/alphapulse/api/news.py b/backend/src/alphapulse/api/news.py index a083c12..882810e 100644 --- a/backend/src/alphapulse/api/news.py +++ b/backend/src/alphapulse/api/news.py @@ -5,7 +5,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.sse import EventSourceResponse, ServerSentEvent from pydantic import BaseModel -from sqlalchemy import func, or_, select, tuple_ +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from alphapulse.core.auth import require_admin @@ -36,8 +36,8 @@ class NewsBrief(BaseModel): class NewsPage(BaseModel): items: list[NewsBrief] - # 形如 "|",原样传回即可取下一页 - next_cursor: str | None + # 符合筛选条件的总条数,用于分页 + total: int class NewsDetail(NewsBrief): @@ -62,35 +62,32 @@ async def list_news( q: str | None = None, start: datetime | None = None, end: datetime | None = None, - cursor: str | None = None, + page: Annotated[int, Query(ge=1)] = 1, limit: Annotated[int, Query(ge=1, le=100)] = 30, ) -> NewsPage: - stmt = select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt")) + conditions = [] if source: - stmt = stmt.where(NewsItem.source_code.in_(source)) + conditions.append(NewsItem.source_code.in_(source)) if q: # 转义 LIKE 通配符,按字面搜索 escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") pattern = f"%{escaped}%" - stmt = stmt.where(or_(NewsItem.title.ilike(pattern, escape="\\"), NewsItem.content_text.ilike(pattern, escape="\\"))) + conditions.append(or_(NewsItem.title.ilike(pattern, escape="\\"), NewsItem.content_text.ilike(pattern, escape="\\"))) if start: - stmt = stmt.where(NewsItem.published_at >= start) + conditions.append(NewsItem.published_at >= start) if end: - stmt = stmt.where(NewsItem.published_at < end) - if cursor: - try: - published_at, news_id = cursor.rsplit("|", 1) - position = (datetime.fromisoformat(published_at), int(news_id)) - if position[0].tzinfo is None: - raise ValueError("cursor 时间缺少时区") - except ValueError: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "cursor 格式无效") from None - stmt = stmt.where(tuple_(NewsItem.published_at, NewsItem.id) < position) - rows = (await session.execute(stmt.order_by(NewsItem.published_at.desc(), NewsItem.id.desc()).limit(limit))).all() + conditions.append(NewsItem.published_at < end) + total = await session.scalar(select(func.count()).select_from(NewsItem).where(*conditions)) + rows = (await session.execute( + select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt")) + .where(*conditions) + .order_by(NewsItem.published_at.desc(), NewsItem.id.desc()) + .offset((page - 1) * limit) + .limit(limit) + )).all() items = [NewsBrief.model_validate({**news.__dict__, "excerpt": excerpt}) for news, excerpt in rows] - next_cursor = f"{items[-1].published_at.isoformat()}|{items[-1].id}" if len(items) == limit else None - return NewsPage(items=items, next_cursor=next_cursor) + return NewsPage(items=items, total=total) @router.get("/stream", response_class=EventSourceResponse) diff --git a/web/src/apis/news.ts b/web/src/apis/news.ts index 2cbf130..ea82f02 100644 --- a/web/src/apis/news.ts +++ b/web/src/apis/news.ts @@ -35,7 +35,8 @@ export interface NewsQuery { q?: string start?: string end?: string - cursor?: string + /** 从 1 开始 */ + page?: number limit?: number } @@ -45,7 +46,7 @@ export interface NewsEvent { kind: "new" | "revised" } -export function listNews(params: NewsQuery): Promise<{ items: NewsBrief[], next_cursor: string | null }> { +export function listNews(params: NewsQuery): Promise<{ items: NewsBrief[], total: number }> { // source 需要序列化为重复参数 source=a&source=b return http.get("/news", { params, paramsSerializer: { indexes: null } }) } diff --git a/web/src/pages/news/index.vue b/web/src/pages/news/index.vue index 1d2812d..77afe57 100644 --- a/web/src/pages/news/index.vue +++ b/web/src/pages/news/index.vue @@ -4,6 +4,7 @@ import { listNews } from "@/apis/news" import { useEventSource } from "@/composables/useEventSource" import { useSourceNames } from "@/composables/useSourceNames" import NewsDrawer from "@/pages/news/components/NewsDrawer.vue" +import { usePersisted } from "@/utils/storage" import { formatTime } from "@/utils/text" const sourceNames = useSourceNames() @@ -13,38 +14,50 @@ const filters = reactive({ range: null as [Date, Date] | null }) const items = ref([]) -const nextCursor = ref(null) +const total = ref(0) +const page = ref(1) +const pageSize = usePersisted("news-page-size", 30) const loading = ref(false) -const finished = ref(false) const pendingCount = ref(0) const openedId = ref(null) +const listBox = ref() -async function load(reset = false) { - if (loading.value || (!reset && finished.value)) - return +// 多次快速翻页时只采用最后一次请求的结果 +let requestSeq = 0 + +async function load() { + const seq = ++requestSeq loading.value = true try { - const page = await listNews({ + const result = await listNews({ source: filters.source.length ? filters.source : undefined, q: filters.q || undefined, start: filters.range?.[0].toISOString(), end: filters.range?.[1].toISOString(), - cursor: reset ? undefined : nextCursor.value ?? undefined, - limit: 30 + page: page.value, + limit: pageSize.value }) - items.value = reset ? page.items : [...items.value, ...page.items] - nextCursor.value = page.next_cursor - finished.value = page.next_cursor === null - if (reset) + if (seq !== requestSeq) + return + items.value = result.items + total.value = result.total + if (page.value === 1) pendingCount.value = 0 + listBox.value?.scrollTo({ top: 0 }) } finally { - loading.value = false + if (seq === requestSeq) + loading.value = false } } function search() { - finished.value = false - load(true) + page.value = 1 + load() +} + +function changePageSize() { + page.value = 1 + load() } // 只提示符合当前来源筛选的新条目;关键字和时间范围由刷新后的查询决定 @@ -53,7 +66,7 @@ const { connected } = useEventSource("/api/news/stream", "news", (eve pendingCount.value++ }) -load(true) +load()