From dd33b7fb61fc6d74f286e2480a7f3ccf94c387c7 Mon Sep 17 00:00:00 2001 From: Xeu Date: Thu, 6 Jun 2024 16:25:12 +0800 Subject: [PATCH] feat: Add pagination support --- client/.env.example | 3 +- client/src/page/feeds.tsx | 51 +++++++++++++++++++++++--- docs/DEPLOY.md | 1 + docs/ENV.md | 73 +++++++++++++++++++------------------ server/src/services/feed.ts | 28 ++++++++++++-- 5 files changed, 111 insertions(+), 45 deletions(-) diff --git a/client/.env.example b/client/.env.example index d4edab9b..3adc0de1 100644 --- a/client/.env.example +++ b/client/.env.example @@ -2,4 +2,5 @@ API_URL=http://localhost:3001 OAUTH_URL=http://localhost:3001/user/github AVATAR=your-avatar-url NAME=your-name -DESCRIPTION=your-description \ No newline at end of file +DESCRIPTION=your-description +PAGE_SIZE=10 \ No newline at end of file diff --git a/client/src/page/feeds.tsx b/client/src/page/feeds.tsx index 56ade985..c900fdbf 100644 --- a/client/src/page/feeds.tsx +++ b/client/src/page/feeds.tsx @@ -1,25 +1,56 @@ import { useContext, useEffect, useRef, useState } from "react" +import { Link, useSearch } from "wouter" import { FeedCard } from "../components/feed_card" import { Waiting } from "../components/loading" import { client } from "../main" import { ProfileContext } from "../state/profile" import { headersWithAuth } from "../utils/auth" +function tryInt(defaultValue: number, ...args: (string | number | undefined | null)[]): number { + for (const v of args) { + if (typeof v === "number") return v + if (typeof v === "string") { + const n = parseInt(v) + if (!isNaN(n)) return n + } + } + return defaultValue +} + export function FeedsPage() { + const query = new URLSearchParams(useSearch()); const profile = useContext(ProfileContext); const [listState, setListState] = useState<'draft' | 'unlisted' | 'normal'>('normal') const [feeds, setFeeds] = useState() + const [hasNext, setHasNext] = useState(false) + const page = tryInt(1, query.get("page")) + const limit = tryInt(10, query.get("limit"), process.env.PAGE_SIZE) const ref = useRef(false) - useEffect(() => { - if (ref.current) return + function fetchFeeds() { client.feed.index.get({ + query: { + page: page, + limit: limit + }, headers: headersWithAuth() }).then(({ data }) => { - if (data) - setFeeds(data) + if (data) { + setFeeds(data.data) + setHasNext(data.hasNext) + } }) + } + useEffect(() => { + if (ref.current) return + fetchFeeds() ref.current = true }, []) + + useEffect(() => { + if (feeds) { + fetchFeeds() + } + }, [query.get("page")]) const feed_filtered = feeds?.filter( ({ draft, listed }: { draft: number | undefined, listed: number | undefined }) => listState === 'draft' ? draft === 1 : listState === 'unlisted' ? listed === 0 : draft != 1 && listed != 0) @@ -50,8 +81,18 @@ export function FeedsPage() { {feed_filtered && feed_filtered.map(({ id, ...feed }: any) => ( ))} +
+ 1 ? '' : 'invisible'}`}> + 上一页 + + + 下一页 + +
) -} \ No newline at end of file +} diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index c893ac18..a1b9330a 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -51,6 +51,7 @@ NAME=Xeu # 昵称,显示在左上角 DESCRIPTION=杂食动物 # 个人描述,显示在左上角昵称下方 AVATAR=https://avatars.githubusercontent.com/u/36541432 # 头像地址,显示在左上角 API_URL=https://rin.xeu.life # 服务端域名,可以先留空后面再改 +PAGE_SIZE=10 # 默认分页大小 SKIP_DEPENDENCY_INSTALL=true UNSTABLE_PRE_BUILD=asdf install bun latest && asdf global bun latest && bun i ``` diff --git a/docs/ENV.md b/docs/ENV.md index 200f8881..b8e5cfce 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -2,49 +2,52 @@ ## 前端环境变量列表 -| 名称 | 是否必须 | 描述 | 默认值| 示例值 | -|--------|--------|------|------|------| -| API_URL | 是 | 后端地址 | 无|http://localhost:3001| -| OAUTH_URL | 否 | OAuth 登录时前端跳转的地址 | ${API_URL}/user/github | http://localhost:3001/user/github| -| AVATAR | 是 | 网站左上角头像地址 | 无 | https://avatars.githubusercontent.com/u/36541432| -|NAME|是|网站左上角名称 & 标题|无| Xeu | -|DESCRIPTION|否|网站左上角描述|无|杂食动物| +| 名称 | 是否必须 | 描述 | 默认值 | 示例值 | +| ----------- | -------- | -------------------------- | ---------------------- | ------------------------------------------------ | +| API_URL | 是 | 后端地址 | 无 | http://localhost:3001 | +| OAUTH_URL | 否 | OAuth 登录时前端跳转的地址 | ${API_URL}/user/github | http://localhost:3001/user/github | +| AVATAR | 是 | 网站左上角头像地址 | 无 | https://avatars.githubusercontent.com/u/36541432 | +| NAME | 是 | 网站左上角名称 & 标题 | 无 | Xeu | +| DESCRIPTION | 否 | 网站左上角描述 | 无 | 杂食动物 | +| PAGE_SIZE | 否 | 默认分页限制 | 10 | 10 | **部署环境变量列表** ->[!CAUTION] -以下环境变量为部署到 Cloudflare Pages 必须,不能修改) -|名称|值|描述| -|---|---|---| -|SKIP_DEPENDENCY_INSTALL|true|跳过默认的 npm install 命令| -|UNSTABLE_PRE_BUILD|asdf install bun latest && asdf global bun latest && bun i|安装并使用 Bun 进行依赖安装| +> [!CAUTION] +> 以下环境变量为部署到 Cloudflare Pages 必须,不能修改) +| 名称 | 值 | 描述 | +| ----------------------- | ---------------------------------------------------------- | --------------------------- | +| SKIP_DEPENDENCY_INSTALL | true | 跳过默认的 npm install 命令 | +| UNSTABLE_PRE_BUILD | asdf install bun latest && asdf global bun latest && bun i | 安装并使用 Bun 进行依赖安装 | ## 后端环境变量列表 **明文环境变量** ->[!NOTE] -以下变量在 Cloudflare Workers 中保持不加密即可 -| 名称 | 是否必须 | 描述 | 默认值| 示例值| -|--------|--------|------|------|------| -|FRONTEND_URL| 暂时必须 | 评论通知 Webhook 时包含评论文章链接时所需,可留空|无|https://xeu.life| -|S3_FOLDER|必须|上传保存图片时资源存放的文件路径|无|images/| +> [!NOTE] +> 以下变量在 Cloudflare Workers 中保持不加密即可 + +| 名称 | 是否必须 | 描述 | 默认值 | 示例值 | +| ------------ | -------- | ------------------------------------------------- | ------ | ---------------- | +| FRONTEND_URL | 暂时必须 | 评论通知 Webhook 时包含评论文章链接时所需,可留空 | 无 | https://xeu.life | +| S3_FOLDER | 必须 | 上传保存图片时资源存放的文件路径 | 无 | images/ | **加密环境变量,以下所有内容均为必须(Webhook 除外)** ->[!NOTE] -由于部署时会清除所有不在 `wrangler.toml` 中的明文变量。\ -以下环境变量在 Cloudflare Workers 中调试完毕后必须加密,否则会被清除 - -| 名称 | 描述 | 示例值| -|--------|------|------| -|GITHUB_CLIENT_ID| Github OAuth 的客户端 ID |Ux66poMrKi1k11M1Q1b2| -|GITHUB_CLIENT_SECRET|Github OAuth 的客户端密钥 |1234567890abcdef1234567890abcdef12345678| -|JWT_SECRET| JWT 认证所需密钥,可为常规格式的任意密码|J0sT%Ch@nge#Me1| -|S3_BUCKET|S3 存储桶名称 | images | -|S3_REGION| S3 存储桶所在区域,如使用 Cloudflare R2 填写 auto 即可| auto | -|S3_ENDPOINT|S3 存储桶接入点地址|https://1234567890abcdef1234567890abcd.r2.cloudflarestorage.com| -|S3_ACCESS_HOST|S3 存储桶访问地址| https://image.xeu.life | -|S3_ACCESS_KEY_ID| S3 存储桶访问所需的 KEY ID,使用 Cloudflare R2 时为拥有 R2 编辑权限的 API 令牌 ID|1234567890abcdef1234567890abcd| -|S3_SECRET_ACCESS_KEY|S3 存储桶访问所需的 Secret,使用 Cloudflare R2 时为拥有 R2 编辑权限的 API 令牌|1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef| -|WEBHOOK_URL|唯一非必须环境变量|https://webhook.example.com/webhook| \ No newline at end of file + +> [!NOTE] +> 由于部署时会清除所有不在 `wrangler.toml` 中的明文变量。\ +> 以下环境变量在 Cloudflare Workers 中调试完毕后必须加密,否则会被清除 + +| 名称 | 描述 | 示例值 | +| -------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| GITHUB_CLIENT_ID | Github OAuth 的客户端 ID | Ux66poMrKi1k11M1Q1b2 | +| GITHUB_CLIENT_SECRET | Github OAuth 的客户端密钥 | 1234567890abcdef1234567890abcdef12345678 | +| JWT_SECRET | JWT 认证所需密钥,可为常规格式的任意密码 | J0sT%Ch@nge#Me1 | +| S3_BUCKET | S3 存储桶名称 | images | +| S3_REGION | S3 存储桶所在区域,如使用 Cloudflare R2 填写 auto 即可 | auto | +| S3_ENDPOINT | S3 存储桶接入点地址 | https://1234567890abcdef1234567890abcd.r2.cloudflarestorage.com | +| S3_ACCESS_HOST | S3 存储桶访问地址 | https://image.xeu.life | +| S3_ACCESS_KEY_ID | S3 存储桶访问所需的 KEY ID,使用 Cloudflare R2 时为拥有 R2 编辑权限的 API 令牌 ID | 1234567890abcdef1234567890abcd | +| S3_SECRET_ACCESS_KEY | S3 存储桶访问所需的 Secret,使用 Cloudflare R2 时为拥有 R2 编辑权限的 API 令牌 | 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef | +| WEBHOOK_URL | 唯一非必须环境变量 | https://webhook.example.com/webhook | diff --git a/server/src/services/feed.ts b/server/src/services/feed.ts index c87dd03c..c92f69a1 100644 --- a/server/src/services/feed.ts +++ b/server/src/services/feed.ts @@ -1,16 +1,18 @@ import { and, desc, eq, or } from "drizzle-orm"; import Elysia, { t } from "elysia"; import type { DB } from "../_worker"; +import type { Env } from "../db/db"; import { feeds } from "../db/schema"; import { setup } from "../setup"; import { bindTagToPost } from "./tag"; -import type { Env } from "../db/db"; export const FeedService = (db: DB, env: Env) => new Elysia({ aot: false }) .use(setup(db, env)) .group('/feed', (group) => group - .get('/', async ({ admin }) => { + .get('/', async ({ admin, query: { page, limit } }) => { + const page_num = (page ? page > 0 ? page : 1 : 1) - 1; + const limit_num = limit ? +limit > 50 ? 50 : +limit : 20; const feed_list = (await db.query.feeds.findMany({ where: admin ? undefined : and(eq(feeds.draft, 0), eq(feeds.listed, 1)), columns: admin ? undefined : { @@ -28,7 +30,9 @@ export const FeedService = (db: DB, env: Env) => new Elysia({ aot: false }) columns: { id: true, username: true, avatar: true } } }, - orderBy: [desc(feeds.createdAt), desc(feeds.updatedAt)] + orderBy: [desc(feeds.createdAt), desc(feeds.updatedAt)], + offset: page_num * limit_num, + limit: limit_num + 1, })).map(({ content, hashtags, summary, ...other }) => { // 提取首图 const img_reg = /!\[.*?\]\((.*?)\)/; @@ -44,7 +48,23 @@ export const FeedService = (db: DB, env: Env) => new Elysia({ aot: false }) ...other } }); - return feed_list; + if (feed_list.length === limit_num + 1) { + feed_list.pop(); + return { + data: feed_list, + hasNext: true + } + } else { + return { + data: feed_list, + hasNext: false + } + } + }, { + query: t.Object({ + page: t.Optional(t.Numeric()), + limit: t.Optional(t.Numeric()) + }) }) .post('/', async ({ admin, set, uid, body: { title, alias, listed, content, summary, draft, tags } }) => { if (!admin) {