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:
1 parent
5c8d773ce1
commit
e80bcebd75
3 files changed
+58
-43
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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<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>
|
||||
Reference in new issue
Block a user