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.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]
# 形如 "<published_at ISO>|<id>",原样传回即可取下一页
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)
+3 -2
View File
@@ -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 } })
}
+36 -19
View File
@@ -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<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 finished = ref(false)
const pendingCount = ref(0)
const openedId = ref<number | null>(null)
const listBox = ref<HTMLElement>()
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 {
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<NewsEvent>("/api/news/stream", "news", (eve
pendingCount.value++
})
load(true)
load()
</script>
<template>
@@ -82,7 +95,7 @@ load(true)
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
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)]"
@@ -107,11 +120,15 @@ load(true)
{{ item.summary || item.excerpt }}
</div>
</div>
<div class="py-4 text-center text-sm text-[var(--el-text-color-secondary)]">
{{ loading ? "加载中…" : finished ? "没有更多了" : "" }}
</div>
<el-empty v-if="!loading && items.length === 0" description="没有符合条件的新闻" />
</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" />
</div>
</template>