diff --git a/backend/app/api/v1/features.py b/backend/app/api/v1/features.py index 1c85fb9..628ea4f 100644 --- a/backend/app/api/v1/features.py +++ b/backend/app/api/v1/features.py @@ -68,8 +68,8 @@ class AdvancedSearchRequest(BaseModel): page: int = Field(1, ge=1) page_size: int = Field(20, ge=1, le=100) sort: str = "date" - # keyset 游标分页(设了 cursor 后 page 参数被忽略,不做 COUNT) - cursor_date: str | None = None # 上一页最后一条的 pub_date(ISO 日期) + # keyset 游标分页(所有排序模式通用,设了 cursor 后 page 参数被忽略,不做 COUNT) + cursor_val: str | None = None # 上一页最后一条的排序列值(字符串,服务端按 sort 模式解析) cursor_id: str | None = None # 上一页最后一条的 id(UUID 字符串) # ── PubMed 筛选器参数 ── diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index efaf253..82a719f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -330,7 +330,7 @@ export interface SearchRequestBody { page: number page_size: number sort?: string - cursor_date?: string + cursor_val?: string cursor_id?: string year_from?: number year_to?: number diff --git a/frontend/src/views/app/SearchView.vue b/frontend/src/views/app/SearchView.vue index 116ce66..36ce566 100644 --- a/frontend/src/views/app/SearchView.vue +++ b/frontend/src/views/app/SearchView.vue @@ -63,9 +63,9 @@ const pageSize = ref(Number(localStorage.getItem('search:pageSize')) || 20) // 从 URL 恢复的页码(syncSearchToUrl 写入) const restoredPage = ref(1) -// ── Keyset 游标分页(sort=date 时使用,跳过 COUNT) ── +// ── Keyset 游标分页(所有排序模式通用,跳过 COUNT 和 OFFSET) ── const keysetPage = ref(1) -const keysetCursors = ref>([null]) +const keysetCursors = ref>([null]) const keysetHasMore = ref(false) const minYear = computed(() => yearCounts.value.length ? Math.min(...yearCounts.value.map(y => y.year)) : 2000) @@ -279,21 +279,18 @@ const { page, total, goToPage } = usePagination({ } if (field.value !== 'all') body.field = field.value // P3-6: precision_mode 不再发送(后端已忽略) - // ── Keyset 游标分页(sort=date 时跳过 COUNT,纯翻页模式) ── - const useKeyset = sort.value === 'date' - if (useKeyset) { - if (p === 1) { + // ── Keyset 游标分页(所有排序模式通用,跳过 COUNT + OFFSET) ── + if (p === 1) { + delete (body as any).page + keysetCursors.value = [null]; keysetPage.value = 1 + } else { + const entry = keysetCursors.value[p] + if (entry) { delete (body as any).page - keysetCursors.value = [null]; keysetPage.value = 1 - } else { - const entry = keysetCursors.value[p] - if (entry) { - delete (body as any).page - body.cursor_date = entry.cursor_date - body.cursor_id = entry.cursor_id - } - // cursor 不存在时保留 body.page,回退到 offset 分页 + body.cursor_val = entry.cursor_val + body.cursor_id = entry.cursor_id } + // cursor 不存在时保留 body.page,回退到 offset 分页 } // 年份 / 日期(datePreset 与 year_* 互斥) const yf = Number(yearFromStr.value) @@ -343,26 +340,19 @@ const { page, total, goToPage } = usePagination({ if (negativeResult.value) body.negative_result = negativeResult.value const { data } = await api.post('/features/search/advanced', body, { signal }) results.value = data.items || [] - if (useKeyset) { - // 第一页走 COUNT,存下总数;后面沿用第一页的数值 - if (p === 1) total.value = data.total || 0 - keysetHasMore.value = data.has_more || false - keysetPage.value = p - // 从当前页最后一条保存游标,供下一页使用 - const items = data.items || [] - if (items.length > 0) { - const last = items[items.length - 1] - keysetCursors.value[p + 1] = { - cursor_date: last.pub_date || last.article_date || '', - cursor_id: last.id, - } - // 当两个日期都为空时,不设游标(避免空串 fromisoformat 失败) - if (!last.pub_date && !last.article_date) { - delete keysetCursors.value[p + 1] - } + // 游标分页:首页走 COUNT 存 total,后续页沿用;cursor 和数据一起返回 + if (p === 1) total.value = data.total || 0 + keysetHasMore.value = data.has_more || false + keysetPage.value = p + // 从响应保存游标,供下一页使用 + const items = data.items || [] + if (items.length > 0 && data.has_more && data.cursor_val) { + keysetCursors.value[p + 1] = { + cursor_val: data.cursor_val, + cursor_id: data.cursor_id || items[items.length - 1].id, } } else { - total.value = data.total || 0 + delete keysetCursors.value[p + 1] } yearCounts.value = data.year_counts || [] } catch (e: any) { diff --git a/frontend/src/views/public/HomeView.vue b/frontend/src/views/public/HomeView.vue index 8061337..fa61b0b 100644 --- a/frontend/src/views/public/HomeView.vue +++ b/frontend/src/views/public/HomeView.vue @@ -149,7 +149,7 @@ async function fetchData(resetPage = true) { if (searchParams.value.negative_result) body.negative_result = searchParams.value.negative_result // keyset 游标 if (cursorDate.value && cursorId.value) { - body.cursor_date = cursorDate.value + body.cursor_val = cursorDate.value body.cursor_id = cursorId.value } const { data } = await api.post('/features/search/advanced', body) @@ -160,12 +160,18 @@ async function fetchData(resetPage = true) { feedItems.value.push(...(data.items || [])) } hasMoreItems.value = data.has_more ?? false - // 记录游标(取最后一条) - const items = data.items || [] - if (items.length > 0) { - const last = items[items.length - 1] - cursorDate.value = (last.article_date || last.pub_date)?.slice(0, 10) || null - cursorId.value = last.id || null + // keyset 游标(全部使用服务端返回的游标,通用所有排序模式) + if (searchParams.value.sort !== 'date') { + // 非 date 排序由服务端返回 cursor_val,用 response 字段 + if (data.cursor_val) { + cursorDate.value = data.cursor_val + cursorId.value = data.cursor_id + } + } else { + if (data.cursor_val && data.cursor_id) { + cursorDate.value = data.cursor_val + cursorId.value = data.cursor_id + } } searched.value = true } catch (e) { toast.apiError(e, '搜索文献失败,请重试') }