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) => (
)}
- {visible.length === 0 ? (
+ {total === 0 ? (
) : (
- {visible.map((p) => (
+ {postcards.map((p) => (
setDetailId(null)}
/>
)}
+
+
+ 显示第 {total === 0 ? 0 : pageStart + 1}-{pageEnd} 张,共 {total} 张
+ {loadingPage ? ",加载中…" : ""}
+
+
+
+
+
+ {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",