diff --git a/app/api/postcards/route.ts b/app/api/postcards/route.ts index a964b6f..ecd0801 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"), 21)); + 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..ad221e3 100644 --- a/app/home-client.tsx +++ b/app/home-client.tsx @@ -1,57 +1,126 @@ "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] + [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"); }; - 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 { @@ -61,9 +130,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 +149,8 @@ export default function HomeClient({ setError(data?.error || "删除失败"); return; } - setPostcards((prev) => prev.filter((p) => p.id !== id)); setDetailId(null); + await loadPage(); } catch { setError("网络错误,请重试"); } finally { @@ -105,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 { @@ -122,7 +190,9 @@ export default function HomeClient({ {FILTERS.map((f) => ( + + {currentPage}/{pageCount} + + + + + + ); } diff --git a/app/page.tsx b/app/page.tsx index a39a0fa..b9838a1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,27 +2,46 @@ import { getCurrentUser } from "@/lib/auth"; import { pool } from "@/lib/db"; import Nav from "@/components/nav"; import HomeClient from "./home-client"; -import type { Postcard } from "@/lib/types"; +import type { Postcard, PostcardCounts } from "@/lib/types"; export const dynamic = "force-dynamic"; +const DEFAULT_PAGE_SIZE = 21; export default async function HomePage() { const user = await getCurrentUser(); // Middleware guarantees a session, but guard anyway. if (!user) return null; - 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 - order by p.created_at desc - ` - ); + 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 + order by p.created_at desc + limit $1 + `, + [DEFAULT_PAGE_SIZE] + ), + pool.query("select status, count(*)::int as count from postcards group by status"), + ]); + + const initialCounts: PostcardCounts = { + all: 0, + available: 0, + claimed: 0, + received: 0, + }; + for (const row of countResult.rows) { + if (row.status in initialCounts) { + initialCounts[row.status as keyof PostcardCounts] = row.count; + initialCounts.all += row.count; + } + } return ( <> @@ -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; 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",