Files
xiaoxia-saas/docs/PAGINATION.md
T
Xiaoxia AI a67f1b2a0f feat(pagination): add generic pagination support
- Implement PaginationParams with offset/limit calculation
- Add PaginationMeta with navigation metadata
- Create generic PaginatedResponse[T] with type safety
- Support both in-memory and database pagination
- Include has_next/has_prev navigation flags
- Add comprehensive pagination documentation
- Include frontend integration examples (React/Vue)
- Cover cursor pagination for large datasets

Phase 4 Task 47/68 completed
2026-06-17 08:49:26 +08:00

6.8 KiB

分页使用指南

📋 概述

小虾 SaaS 提供统一的分页接口,支持内存分页和数据库分页。


🔧 基本使用

1. API 端点添加分页

from fastapi import APIRouter, Depends
from packages.application.common.pagination import PaginationParams, PaginatedResponse

router = APIRouter()

@router.get("/projects", response_model=PaginatedResponse[ProjectResponse])
async def list_projects(
    pagination: PaginationParams = Depends(),
    user_id: str = Depends(get_current_user_id),
):
    # 获取分页数据
    projects, total = project_repo.find_by_user_paginated(
        user_id=user_id,
        offset=pagination.offset,
        limit=pagination.limit,
    )
    
    # 返回分页响应
    return PaginatedResponse.create(
        data=projects,
        params=pagination,
        total=total,
    )

2. 请求分页数据

# 默认第一页,每页 20 条
GET /api/v1/projects

# 指定页码和每页数量
GET /api/v1/projects?page=2&page_size=10

# 获取更多数据
GET /api/v1/projects?page=1&page_size=50

3. 响应格式

{
  "data": [
    {
      "id": "project-1",
      "name": "项目 1"
    },
    {
      "id": "project-2",
      "name": "项目 2"
    }
  ],
  "pagination": {
    "page": 1,
    "page_size": 20,
    "total": 156,
    "total_pages": 8,
    "has_next": true,
    "has_prev": false
  }
}

🗄️ Repository 实现

InMemory Repository(内存分页)

from packages.application.common.pagination import paginate, PaginationParams

class InMemoryProjectRepository:
    def find_by_workspace_paginated(
        self,
        workspace_id: str,
        pagination: PaginationParams,
    ):
        # 获取所有项目
        all_projects = [p for p in self._storage.values() 
                       if p.workspace_id == workspace_id]
        
        # 使用内存分页
        return paginate(all_projects, pagination)

PostgreSQL Repository(数据库分页)

class PostgresProjectRepository:
    def find_by_workspace_paginated(
        self,
        workspace_id: str,
        offset: int,
        limit: int,
    ) -> tuple[List[Project], int]:
        conn = self._get_connection()
        try:
            with conn.cursor() as cur:
                # 获取总数
                cur.execute(
                    "SELECT COUNT(*) FROM projects WHERE workspace_id = %s",
                    (workspace_id,)
                )
                total = cur.fetchone()["count"]
                
                # 获取分页数据
                cur.execute("""
                    SELECT * FROM projects 
                    WHERE workspace_id = %s 
                    ORDER BY created_at DESC
                    LIMIT %s OFFSET %s
                """, (workspace_id, limit, offset))
                
                projects = [self._row_to_project(row) for row in cur.fetchall()]
                
                return projects, total
        finally:
            conn.close()

📱 前端使用

React 示例

import { useState, useEffect } from 'react';

function ProjectList() {
  const [projects, setProjects] = useState([]);
  const [pagination, setPagination] = useState(null);
  const [page, setPage] = useState(1);

  useEffect(() => {
    fetch(`/api/v1/projects?page=${page}&page_size=20`)
      .then(res => res.json())
      .then(data => {
        setProjects(data.data);
        setPagination(data.pagination);
      });
  }, [page]);

  return (
    <div>
      <ul>
        {projects.map(p => <li key={p.id}>{p.name}</li>)}
      </ul>
      
      <Pagination
        current={pagination?.page}
        total={pagination?.total}
        pageSize={pagination?.page_size}
        onChange={setPage}
        showPrevNext={true}
        disabled={{
          prev: !pagination?.has_prev,
          next: !pagination?.has_next
        }}
      />
    </div>
  );
}

Vue 示例

<template>
  <div>
    <ul>
      <li v-for="project in projects" :key="project.id">
        {{ project.name }}
      </li>
    </ul>
    
    <el-pagination
      :current-page="pagination.page"
      :page-size="pagination.page_size"
      :total="pagination.total"
      @current-change="handlePageChange"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      projects: [],
      pagination: {
        page: 1,
        page_size: 20,
        total: 0
      }
    };
  },
  methods: {
    async fetchProjects(page = 1) {
      const response = await fetch(
        `/api/v1/projects?page=${page}&page_size=20`
      );
      const data = await response.json();
      this.projects = data.data;
      this.pagination = data.pagination;
    },
    handlePageChange(page) {
      this.fetchProjects(page);
    }
  },
  mounted() {
    this.fetchProjects();
  }
};
</script>

🎯 最佳实践

1. 合理的页面大小

# ✅ 推荐
page_size: int = Field(20, ge=1, le=100)  # 默认 20,最大 100

# ❌ 不推荐
page_size: int = Field(1000, ge=1, le=10000)  # 太大,性能差

2. 默认排序

# ✅ 总是指定排序
ORDER BY created_at DESC

# ❌ 不指定排序(结果不稳定)
SELECT * FROM projects LIMIT 20

3. 使用游标分页(大数据量)

# 基于 ID 的游标分页(更快)
@router.get("/projects")
async def list_projects(
    cursor: Optional[str] = None,  # 上一页最后一个 ID
    limit: int = 20,
):
    if cursor:
        projects = project_repo.find_after_cursor(cursor, limit)
    else:
        projects = project_repo.find_first_page(limit)
    
    return {
        "data": projects,
        "next_cursor": projects[-1].id if projects else None,
        "has_more": len(projects) == limit
    }

4. 缓存总数(可选)

# 对于不常变化的列表,缓存总数
from functools import lru_cache

@lru_cache(maxsize=128)
def get_total_projects(workspace_id: str) -> int:
    return project_repo.count_by_workspace(workspace_id)

📊 性能对比

Offset/Limit 分页

优点:

  • 简单易用
  • 支持跳转到任意页

缺点:

  • 深分页慢(OFFSET 10000 需要扫描 10000 行)
  • 数据变化时可能重复/遗漏

适用场景:

  • 数据量小(< 10,000 条)
  • 用户很少翻到后面

游标分页

优点:

  • 性能稳定(始终快速)
  • 不受数据变化影响

缺点:

  • 不支持跳转
  • 只能上一页/下一页

适用场景:

  • 数据量大(> 100,000 条)
  • 无限滚动

🔗 相关资源


最后更新: 2026-06-17