Replace news infinite scroll with page-based pagination

The news list API now takes page and limit and returns the total count
instead of a cursor, and the news page uses el-pagination with a
persisted page size.
This commit is contained in:
eezd committed 2026-10-13 15:10:04 +08:00
1 parent 5c8d773ce1
commit e80bcebd75
3 files changed
+57 -42

No files matched your search

+18 -21
View File
@@ -5,7 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.sse import EventSourceResponse, ServerSentEvent from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, or_, select, tuple_ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.core.auth import require_admin from alphapulse.core.auth import require_admin
@@ -36,8 +36,8 @@ class NewsBrief(BaseModel):
class NewsPage(BaseModel): class NewsPage(BaseModel):
items: list[NewsBrief] items: list[NewsBrief]
# 形如 "<published_at ISO>|<id>",原样传回即可取下一页 # 符合筛选条件的总条数,用于分页
next_cursor: str | None total: int
class NewsDetail(NewsBrief): class NewsDetail(NewsBrief):
@@ -62,35 +62,32 @@ async def list_news(
q: str | None = None, q: str | None = None,
start: datetime | None = None, start: datetime | None = None,
end: 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, limit: Annotated[int, Query(ge=1, le=100)] = 30,
) -> NewsPage: ) -> NewsPage:
stmt = select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt")) conditions = []
if source: if source:
stmt = stmt.where(NewsItem.source_code.in_(source)) conditions.append(NewsItem.source_code.in_(source))
if q: if q:
# 转义 LIKE 通配符,按字面搜索 # 转义 LIKE 通配符,按字面搜索
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped}%" 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: if start:
stmt = stmt.where(NewsItem.published_at >= start) conditions.append(NewsItem.published_at >= start)
if end: if end:
stmt = stmt.where(NewsItem.published_at < end) conditions.append(NewsItem.published_at < end)
if cursor: total = await session.scalar(select(func.count()).select_from(NewsItem).where(*conditions))
try: rows = (await session.execute(
published_at, news_id = cursor.rsplit("|", 1) select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt"))
position = (datetime.fromisoformat(published_at), int(news_id)) .where(*conditions)
if position[0].tzinfo is None: .order_by(NewsItem.published_at.desc(), NewsItem.id.desc())
raise ValueError("cursor 时间缺少时区") .offset((page - 1) * limit)
except ValueError: .limit(limit)
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "cursor 格式无效") from None )).all()
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()
items = [NewsBrief.model_validate({**news.__dict__, "excerpt": excerpt}) for news, excerpt in rows] 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, total=total)
return NewsPage(items=items, next_cursor=next_cursor)
@router.get("/stream", response_class=EventSourceResponse) @router.get("/stream", response_class=EventSourceResponse)
+3 -2
View File
@@ -35,7 +35,8 @@ export interface NewsQuery {
q?: string q?: string
start?: string start?: string
end?: string end?: string
cursor?: string /** 从 1 开始 */
page?: number
limit?: number limit?: number
} }
@@ -45,7 +46,7 @@ export interface NewsEvent {
kind: "new" | "revised" 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 // source 需要序列化为重复参数 source=a&source=b
return http.get("/news", { params, paramsSerializer: { indexes: null } }) return http.get("/news", { params, paramsSerializer: { indexes: null } })
} }
+36 -19
View File
@@ -4,6 +4,7 @@ import { listNews } from "@/apis/news"
import { useEventSource } from "@/composables/useEventSource" import { useEventSource } from "@/composables/useEventSource"
import { useSourceNames } from "@/composables/useSourceNames" import { useSourceNames } from "@/composables/useSourceNames"
import NewsDrawer from "@/pages/news/components/NewsDrawer.vue" import NewsDrawer from "@/pages/news/components/NewsDrawer.vue"
import { usePersisted } from "@/utils/storage"
import { formatTime } from "@/utils/text" import { formatTime } from "@/utils/text"
const sourceNames = useSourceNames() const sourceNames = useSourceNames()
@@ -13,38 +14,50 @@ const filters = reactive({
range: null as [Date, Date] | null range: null as [Date, Date] | null
}) })
const items = ref<NewsBrief[]>([]) const items = ref<NewsBrief[]>([])
const nextCursor = ref<string | null>(null) const total = ref(0)
const page = ref(1)
const pageSize = usePersisted("news-page-size", 30)
const loading = ref(false) const loading = ref(false)
const finished = ref(false)
const pendingCount = ref(0) const pendingCount = ref(0)
const openedId = ref<number | null>(null) const openedId = ref<number | null>(null)
const listBox = ref<HTMLElement>()
async function load(reset = false) { // 多次快速翻页时只采用最后一次请求的结果
if (loading.value || (!reset && finished.value)) let requestSeq = 0
return
async function load() {
const seq = ++requestSeq
loading.value = true loading.value = true
try { try {
const page = await listNews({ const result = await listNews({
source: filters.source.length ? filters.source : undefined, source: filters.source.length ? filters.source : undefined,
q: filters.q || undefined, q: filters.q || undefined,
start: filters.range?.[0].toISOString(), start: filters.range?.[0].toISOString(),
end: filters.range?.[1].toISOString(), end: filters.range?.[1].toISOString(),
cursor: reset ? undefined : nextCursor.value ?? undefined, page: page.value,
limit: 30 limit: pageSize.value
}) })
items.value = reset ? page.items : [...items.value, ...page.items] if (seq !== requestSeq)
nextCursor.value = page.next_cursor return
finished.value = page.next_cursor === null items.value = result.items
if (reset) total.value = result.total
if (page.value === 1)
pendingCount.value = 0 pendingCount.value = 0
listBox.value?.scrollTo({ top: 0 })
} finally { } finally {
if (seq === requestSeq)
loading.value = false loading.value = false
} }
} }
function search() { function search() {
finished.value = false page.value = 1
load(true) load()
}
function changePageSize() {
page.value = 1
load()
} }
// 只提示符合当前来源筛选的新条目;关键字和时间范围由刷新后的查询决定 // 只提示符合当前来源筛选的新条目;关键字和时间范围由刷新后的查询决定
@@ -53,7 +66,7 @@ const { connected } = useEventSource<NewsEvent>("/api/news/stream", "news", (eve
pendingCount.value++ pendingCount.value++
}) })
load(true) load()
</script> </script>
<template> <template>
@@ -82,7 +95,7 @@ load(true)
class="cursor-pointer" @click="search" class="cursor-pointer" @click="search"
/> />
<div v-infinite-scroll="() => load()" :infinite-scroll-disabled="loading || finished" class="min-h-0 flex-1 overflow-auto"> <div ref="listBox" v-loading="loading" class="min-h-0 flex-1 overflow-auto">
<div <div
v-for="item in items" :key="item.id" v-for="item in items" :key="item.id"
class="cursor-pointer border-b border-b-solid border-[var(--el-border-color-lighter)] px-2 py-3 hover:bg-[var(--el-fill-color-light)]" class="cursor-pointer border-b border-b-solid border-[var(--el-border-color-lighter)] px-2 py-3 hover:bg-[var(--el-fill-color-light)]"
@@ -107,11 +120,15 @@ load(true)
{{ item.summary || item.excerpt }} {{ item.summary || item.excerpt }}
</div> </div>
</div> </div>
<div class="py-4 text-center text-sm text-[var(--el-text-color-secondary)]"> <el-empty v-if="!loading && items.length === 0" description="没有符合条件的新闻" />
{{ loading ? "加载中…" : finished ? "没有更多了" : "" }}
</div>
</div> </div>
<el-pagination
v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[20, 30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper" background class="justify-end"
@current-change="load" @size-change="changePageSize"
/>
<NewsDrawer v-model="openedId" /> <NewsDrawer v-model="openedId" />
</div> </div>
</template> </template>