From 5391b5c5a52a1dd38c0ed64211b3bad4b107ea71 Mon Sep 17 00:00:00 2001 From: AlexWei2020 Date: Tue, 7 Jul 2026 10:12:40 +0800 Subject: [PATCH 1/3] Add paginated postcard filtering and counts --- app/api/postcards/route.ts | 86 ++++++++++++++----- app/home-client.tsx | 163 +++++++++++++++++++++++++++++++------ app/page.tsx | 47 ++++++++--- lib/types.ts | 3 + 4 files changed, 240 insertions(+), 59 deletions(-) diff --git a/app/api/postcards/route.ts b/app/api/postcards/route.ts index a964b6f..085df33 100644 --- a/app/api/postcards/route.ts +++ b/app/api/postcards/route.ts @@ -3,10 +3,16 @@ import { pool } from "@/lib/db"; import { getCurrentUser } from "@/lib/auth"; import { ensureImageHashColumn, normalizeImageHash } from "@/lib/postcard-image-hash"; import { ensurePostcardMetadataColumns } from "@/lib/schema"; +import type { PostcardCounts } from "@/lib/types"; // GET /api/postcards -> all postcards (newest first) // GET /api/postcards?status=available // GET /api/postcards?mine=1 -> postcards claimed by the current user +function positiveInt(value: string | null, fallback: number) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + export async function GET(request: Request) { const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 }); @@ -14,37 +20,77 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const status = searchParams.get("status"); const mine = searchParams.get("mine"); + const page = positiveInt(searchParams.get("page"), 1); + const pageSize = Math.min(100, positiveInt(searchParams.get("pageSize"), 20)); + const offset = (page - 1) * pageSize; - const conditions: string[] = []; - const params: unknown[] = []; + const baseConditions: string[] = []; + const baseParams: unknown[] = []; if (mine === "1") { - params.push(user.id); - conditions.push(`p.claimer_id = $${params.length}`); + baseParams.push(user.id); + baseConditions.push(`p.claimer_id = $${baseParams.length}`); } + + const conditions = [...baseConditions]; + const params = [...baseParams]; if (status && ["available", "claimed", "received"].includes(status)) { params.push(status); conditions.push(`p.status = $${params.length}`); } const where = conditions.length ? `where ${conditions.join(" and ")}` : ""; + const baseWhere = baseConditions.length ? `where ${baseConditions.join(" and ")}` : ""; + + params.push(pageSize, offset); + const limitParam = params.length - 1; + const offsetParam = params.length; + + const [result, countResult] = await Promise.all([ + pool.query( + ` + select + p.*, + up.nickname as uploader_nickname, + cl.nickname as claimer_nickname + from postcards p + left join users up on p.uploader_id = up.id + left join users cl on p.claimer_id = cl.id + ${where} + order by p.created_at desc + limit $${limitParam} offset $${offsetParam} + `, + params + ), + pool.query( + ` + select p.status, count(*)::int as count + from postcards p + ${baseWhere} + group by p.status + `, + baseParams + ), + ]); + + const counts: PostcardCounts = { all: 0, available: 0, claimed: 0, received: 0 }; + for (const row of countResult.rows) { + if (row.status in counts) { + counts[row.status as keyof PostcardCounts] = row.count; + counts.all += row.count; + } + } + const total = + status && ["available", "claimed", "received"].includes(status) + ? counts[status as keyof PostcardCounts] + : counts.all; - const result = await pool.query( - ` - select - p.*, - up.nickname as uploader_nickname, - cl.nickname as claimer_nickname - from postcards p - left join users up on p.uploader_id = up.id - left join users cl on p.claimer_id = cl.id - ${where} - order by p.created_at desc - `, - params - ); - - return NextResponse.json({ postcards: result.rows, currentUserId: user.id }); + return NextResponse.json({ + postcards: result.rows, + counts, + pagination: { page, pageSize, total }, + currentUserId: user.id, + }); } // POST /api/postcards { imageUrl, recipientName, pickupLocation?, note?, sentAt?, arrivedAt?, imageHash? } diff --git a/app/home-client.tsx b/app/home-client.tsx index bc32bdc..2cae716 100644 --- a/app/home-client.tsx +++ b/app/home-client.tsx @@ -1,52 +1,115 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import PostcardCard from "@/components/postcard-card"; import PostcardDetail from "@/components/postcard-detail"; -import type { Postcard, PostcardStatus, PostcardUpdateInput } from "@/lib/types"; +import type { + Postcard, + PostcardCounts, + PostcardFilter, + PostcardUpdateInput, +} from "@/lib/types"; -type Filter = "all" | PostcardStatus; - -const FILTERS: { key: Filter; label: string }[] = [ +const FILTERS: { key: PostcardFilter; label: string }[] = [ { key: "all", label: "全部" }, { key: "available", label: "待认领" }, { key: "claimed", label: "已认领" }, { key: "received", label: "已收到" }, ]; +type PostcardsPageResponse = { + postcards?: Postcard[]; + counts?: PostcardCounts; + pagination?: { + page?: number; + pageSize?: number; + total?: number; + }; + error?: string; +}; + export default function HomeClient({ initialPostcards, + initialCounts, + initialPageSize, currentUserId, }: { initialPostcards: Postcard[]; + initialCounts: PostcardCounts; + initialPageSize: number; currentUserId: string; }) { const [postcards, setPostcards] = useState(initialPostcards); - const [filter, setFilter] = useState("all"); + const [counts, setCounts] = useState(initialCounts); + const [filter, setFilter] = useState("all"); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); const [detailId, setDetailId] = useState(null); + const [pageSize, setPageSize] = useState(initialPageSize); + const [page, setPage] = useState(1); + const [loadingPage, setLoadingPage] = useState(false); - const visible = useMemo( - () => (filter === "all" ? postcards : postcards.filter((p) => p.status === filter)), - [postcards, filter] - ); + const total = counts[filter]; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(page, pageCount); + const pageStart = (currentPage - 1) * pageSize; + const pageEnd = Math.min(pageStart + postcards.length, total); - const counts = useMemo( - () => ({ - all: postcards.length, - available: postcards.filter((p) => p.status === "available").length, - claimed: postcards.filter((p) => p.status === "claimed").length, - received: postcards.filter((p) => p.status === "received").length, - }), - [postcards] - ); + useEffect(() => { + setPage((current) => Math.min(current, pageCount)); + }, [pageCount]); const detail = useMemo( () => postcards.find((p) => p.id === detailId) ?? null, [postcards, detailId] ); + const loadPage = async ({ + nextFilter = filter, + nextPage = currentPage, + nextPageSize = pageSize, + }: { + nextFilter?: PostcardFilter; + nextPage?: number; + nextPageSize?: number; + } = {}) => { + setLoadingPage(true); + setError(null); + try { + const params = new URLSearchParams({ + page: String(nextPage), + pageSize: String(nextPageSize), + }); + if (nextFilter !== "all") params.set("status", nextFilter); + + const res = await fetch(`/api/postcards?${params.toString()}`); + const data = (await res.json().catch(() => ({}))) as PostcardsPageResponse; + if (!res.ok) { + setError(data?.error || "加载失败"); + return; + } + + const nextCounts = data.counts || counts; + const nextTotal = nextCounts[nextFilter]; + const nextPageCount = Math.max(1, Math.ceil(nextTotal / nextPageSize)); + const safePage = Math.min(nextPage, nextPageCount); + if (safePage !== nextPage) { + await loadPage({ nextFilter, nextPage: safePage, nextPageSize }); + return; + } + + setFilter(nextFilter); + setPageSize(nextPageSize); + setPage(safePage); + setCounts(nextCounts); + setPostcards(data.postcards || []); + } catch { + setError("网络错误,请重试"); + } finally { + setLoadingPage(false); + } + }; + const act = async (id: string, path: string) => { await actWithMethod(id, path, "POST"); }; @@ -61,9 +124,8 @@ export default function HomeClient({ setError(data?.error || "操作失败"); return; } - setPostcards((prev) => - prev.map((p) => (p.id === id ? { ...p, ...data.postcard } : p)) - ); + setDetailId(null); + await loadPage(); } catch { setError("网络错误,请重试"); } finally { @@ -81,8 +143,8 @@ export default function HomeClient({ setError(data?.error || "删除失败"); return; } - setPostcards((prev) => prev.filter((p) => p.id !== id)); setDetailId(null); + await loadPage(); } catch { setError("网络错误,请重试"); } finally { @@ -122,7 +184,9 @@ export default function HomeClient({ {FILTERS.map((f) => ( + + {currentPage}/{pageCount} + + + + + + {error && (

{error}

)} - {visible.length === 0 ? ( + {total === 0 ? (
这里还没有明信片。上传第一张吧!
) : (
- {visible.map((p) => ( + {postcards.map((p) => ( @@ -36,6 +55,8 @@ export default async function HomePage() {
diff --git a/lib/types.ts b/lib/types.ts index 044e407..295148a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,7 @@ export type PostcardStatus = "available" | "claimed" | "received"; +export type PostcardFilter = "all" | PostcardStatus; + +export type PostcardCounts = Record; export type Postcard = { id: string; From ca257d82b0b053c0274eba882efcbd783e3cfc43 Mon Sep 17 00:00:00 2001 From: AlexWei2020 Date: Tue, 7 Jul 2026 10:36:12 +0800 Subject: [PATCH 2/3] Refine postcard pagination defaults and controls --- app/api/postcards/route.ts | 2 +- app/home-client.tsx | 121 +++++++++++++++++++++---------------- app/page.tsx | 2 +- 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/app/api/postcards/route.ts b/app/api/postcards/route.ts index 085df33..ecd0801 100644 --- a/app/api/postcards/route.ts +++ b/app/api/postcards/route.ts @@ -21,7 +21,7 @@ export async function GET(request: Request) { const status = searchParams.get("status"); const mine = searchParams.get("mine"); const page = positiveInt(searchParams.get("page"), 1); - const pageSize = Math.min(100, positiveInt(searchParams.get("pageSize"), 20)); + const pageSize = Math.min(100, positiveInt(searchParams.get("pageSize"), 21)); const offset = (page - 1) * pageSize; const baseConditions: string[] = []; diff --git a/app/home-client.tsx b/app/home-client.tsx index 2cae716..ad221e3 100644 --- a/app/home-client.tsx +++ b/app/home-client.tsx @@ -61,7 +61,7 @@ export default function HomeClient({ const detail = useMemo( () => postcards.find((p) => p.id === detailId) ?? null, - [postcards, detailId] + [postcards, detailId], ); const loadPage = async ({ @@ -83,7 +83,9 @@ export default function HomeClient({ if (nextFilter !== "all") params.set("status", nextFilter); const res = await fetch(`/api/postcards?${params.toString()}`); - const data = (await res.json().catch(() => ({}))) as PostcardsPageResponse; + const data = (await res + .json() + .catch(() => ({}))) as PostcardsPageResponse; if (!res.ok) { setError(data?.error || "加载失败"); return; @@ -114,7 +116,11 @@ export default function HomeClient({ await actWithMethod(id, path, "POST"); }; - const actWithMethod = async (id: string, path: string, method: "POST" | "DELETE") => { + const actWithMethod = async ( + id: string, + path: string, + method: "POST" | "DELETE", + ) => { setBusyId(id); setError(null); try { @@ -167,7 +173,7 @@ export default function HomeClient({ return false; } setPostcards((prev) => - prev.map((p) => (p.id === id ? { ...p, ...data.postcard } : p)) + prev.map((p) => (p.id === id ? { ...p, ...data.postcard } : p)), ); return true; } catch { @@ -198,53 +204,6 @@ export default function HomeClient({ ))} -
-
- 显示第 {total === 0 ? 0 : pageStart + 1}-{pageEnd} 张,共 {total} 张 - {loadingPage ? ",加载中…" : ""} -
-
- -
- - - {currentPage}/{pageCount} - - -
-
-
- {error && (

{error} @@ -253,7 +212,11 @@ export default function HomeClient({ {total === 0 ? (

- 这里还没有明信片。上传第一张吧! + 这里还没有明信片。 + + 上传第一张 + + 吧!
) : (
@@ -287,6 +250,60 @@ export default function HomeClient({ onClose={() => setDetailId(null)} /> )} +
+
+ 显示第 {total === 0 ? 0 : pageStart + 1}-{pageEnd} 张,共 {total} 张 + {loadingPage ? ",加载中…" : ""} +
+
+
+ + + {currentPage}/{pageCount} + + +
+ +
+
); } diff --git a/app/page.tsx b/app/page.tsx index 3b4187e..b9838a1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,7 +5,7 @@ import HomeClient from "./home-client"; import type { Postcard, PostcardCounts } from "@/lib/types"; export const dynamic = "force-dynamic"; -const DEFAULT_PAGE_SIZE = 20; +const DEFAULT_PAGE_SIZE = 21; export default async function HomePage() { const user = await getCurrentUser(); From bd4c190e3c46170b40502d2968f7b17e84a3bda9 Mon Sep 17 00:00:00 2001 From: AlexWei2020 Date: Tue, 7 Jul 2026 10:42:14 +0800 Subject: [PATCH 3/3] Bump version to 0.1.1 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c621fcd..5d69216 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postback", - "version": "0.1.0", + "version": "0.1.1", "private": true, "scripts": { "dev": "next dev",