Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions app/api/postcards/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { ensureImageHashColumn, normalizeImageHash } from "@/lib/postcard-image-hash";
import { ensurePostcardMetadataColumns, ensurePostcardHiddenColumn } from "@/lib/schema";
import { normalizeImageHash } from "@/lib/postcard-image-hash";
import { ensureSchema } from "@/lib/schema";
import type { PostcardCounts } from "@/lib/types";

// GET /api/postcards -> plaza, all postcards (newest first)
Expand All @@ -15,7 +15,8 @@ function positiveInt(value: string | null, fallback: number) {
}

export async function GET(request: Request) {
const user = await getCurrentUser();
// getCurrentUser + one-time schema/index check are independent.
const [user, schema] = await Promise.all([getCurrentUser(), ensureSchema()]);
if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 });

const { searchParams } = new URL(request.url);
Expand Down Expand Up @@ -82,8 +83,7 @@ export async function GET(request: Request) {
// 广场视图:排除被认领人隐藏的明信片。
const baseConditions: string[] = [];
const baseParams: unknown[] = [];
const hiddenReady = await ensurePostcardHiddenColumn();
if (hiddenReady) baseConditions.push("p.hidden_by_claimer is not true");
if (schema.hidden) baseConditions.push("p.hidden_by_claimer is not true");

const conditions = [...baseConditions];
const params = [...baseParams];
Expand Down Expand Up @@ -179,8 +179,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "取件地点过长" }, { status: 400 });
}

const canStoreMetadata = await ensurePostcardMetadataColumns();
const canStoreHash = imageHash ? await ensureImageHashColumn() : false;
const schema = await ensureSchema();
const canStoreMetadata = schema.metadata;
const canStoreHash = imageHash ? schema.hash : false;
let result;

if (canStoreHash && canStoreMetadata) {
Expand Down
58 changes: 40 additions & 18 deletions app/home-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,16 @@ export default function HomeClient({
nextFilter = filter,
nextPage = currentPage,
nextPageSize = pageSize,
silent = false,
}: {
nextFilter?: PostcardFilter;
nextPage?: number;
nextPageSize?: number;
// silent: reconcile in the background after an optimistic action — no
// grid dimming, no scroll jump. Used so 认领/签收 feel instant.
silent?: boolean;
} = {}) => {
setLoadingPage(true);
if (!silent) setLoadingPage(true);
setError(null);
Comment on lines +84 to 85
try {
const params = new URLSearchParams({
Expand All @@ -100,7 +104,7 @@ export default function HomeClient({
const nextPageCount = Math.max(1, Math.ceil(nextTotal / nextPageSize));
const safePage = Math.min(nextPage, nextPageCount);
if (safePage !== nextPage) {
await loadPage({ nextFilter, nextPage: safePage, nextPageSize });
await loadPage({ nextFilter, nextPage: safePage, nextPageSize, silent });
return;
}

Expand All @@ -109,18 +113,29 @@ export default function HomeClient({
setPage(safePage);
setCounts(nextCounts);
setPostcards(data.postcards || []);
// 换页/换板块后把列表顶部滚回视野,避免停在旧的滚动位置。
requestAnimationFrame(() =>
gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }),
);
// 换页/换板块后把列表顶部滚回视野;静默重同步(认领后)不打断滚动。
if (!silent) {
requestAnimationFrame(() =>
gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }),
);
}
} catch {
setError("网络错误,请重试");
if (!silent) setError("网络错误,请重试");
} finally {
setLoadingPage(false);
if (!silent) setLoadingPage(false);
setPendingFilter(null);
}
};

// 认领/签收/取消:先本地即时改状态(乐观),再后台静默与服务器对齐。
// 让点击"秒响应",不再整格变灰、跳回顶部。
const optimisticStatus: Record<string, (p: Postcard) => Partial<Postcard>> = {
"claim:POST": () => ({ status: "claimed", claimer_id: currentUserId }),
"receive:POST": () => ({ status: "received" }),
"claim:DELETE": () => ({ status: "available", claimer_id: null }),
"receive:DELETE": () => ({ status: "claimed" }),
};

const act = async (id: string, path: string) => {
await actWithMethod(id, path, "POST");
};
Expand All @@ -132,38 +147,44 @@ export default function HomeClient({
) => {
setBusyId(id);
setError(null);
const patch = optimisticStatus[`${path}:${method}`];
if (patch) {
setPostcards((prev) =>
prev.map((p) => (p.id === id ? { ...p, ...patch(p) } : p)),
);
}
try {
const res = await fetch(`/api/postcards/${id}/${path}`, { method });
const data = await res.json();
if (!res.ok) {
setError(data?.error || "操作失败");
return;
} else {
setDetailId(null);
}
setDetailId(null);
await loadPage();
} catch {
setError("网络错误,请重试");
} finally {
setBusyId(null);
// 后台对齐:修正计数、分页归属,成功/失败都以服务器为准。
loadPage({ silent: true });
}
};

const remove = async (id: string) => {
setBusyId(id);
setError(null);
// 乐观移除,弹窗立即关闭。
setPostcards((prev) => prev.filter((p) => p.id !== id));
setDetailId(null);
try {
const res = await fetch(`/api/postcards/${id}`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || "删除失败");
return;
}
setDetailId(null);
await loadPage();
if (!res.ok) setError(data?.error || "删除失败");
} catch {
setError("网络错误,请重试");
} finally {
setBusyId(null);
loadPage({ silent: true });
}
};

Expand Down Expand Up @@ -237,12 +258,13 @@ export default function HomeClient({
loadingPage ? "pointer-events-none opacity-40" : ""
}`}
>
{postcards.map((p) => (
{postcards.map((p, index) => (
<PostcardCard
key={p.id}
postcard={p}
currentUserId={currentUserId}
busy={busyId === p.id}
priority={index < 3}
onClaim={(id) => act(id, "claim")}
onReceive={(id) => act(id, "receive")}
onCancelClaim={(id) => actWithMethod(id, "claim", "DELETE")}
Expand Down
56 changes: 30 additions & 26 deletions app/mine/mine-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,15 @@ export default function MineClient({
nextScope = scope,
nextPage = currentPage,
nextPageSize = pageSize,
silent = false,
}: {
nextScope?: Scope;
nextPage?: number;
nextPageSize?: number;
// silent: 乐观操作后的后台对齐,不变灰、不跳顶部。
silent?: boolean;
} = {}) => {
setLoadingPage(true);
if (!silent) setLoadingPage(true);
setError(null);
Comment on lines +79 to 80
try {
const params = new URLSearchParams({
Expand All @@ -96,7 +99,7 @@ export default function MineClient({
const nextPageCount = Math.max(1, Math.ceil(nextTotal / nextPageSize));
const safePage = Math.min(nextPage, nextPageCount);
if (safePage !== nextPage) {
await loadPage({ nextScope, nextPage: safePage, nextPageSize });
await loadPage({ nextScope, nextPage: safePage, nextPageSize, silent });
return;
}

Expand All @@ -105,14 +108,16 @@ export default function MineClient({
setPage(safePage);
setScopeCounts(nextCounts);
setPostcards(data.postcards || []);
// 换页/换 tab 后把列表顶部滚回视野,避免停在旧的滚动位置。
requestAnimationFrame(() =>
gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }),
);
// 换页/换 tab 后把列表顶部滚回视野;静默重同步不打断滚动。
if (!silent) {
requestAnimationFrame(() =>
gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }),
);
}
} catch {
setError("网络错误,请重试");
if (!silent) setError("网络错误,请重试");
} finally {
setLoadingPage(false);
if (!silent) setLoadingPage(false);
setPendingScope(null);
}
};
Expand All @@ -124,38 +129,39 @@ export default function MineClient({
const receiveWithMethod = async (id: string, method: "POST" | "DELETE") => {
setBusyId(id);
setError(null);
// 乐观改状态:确认签收 -> received,取消签收 -> claimed。
const nextStatus = method === "POST" ? "received" : "claimed";
setPostcards((list) =>
list.map((p) => (p.id === id ? { ...p, status: nextStatus } : p)),
);
try {
const res = await fetch(`/api/postcards/${id}/receive`, { method });
const data = await res.json();
if (!res.ok) {
setError(data?.error || "操作失败");
return;
}
setDetailId(null);
await loadPage();
if (!res.ok) setError(data?.error || "操作失败");
else setDetailId(null);
} catch {
setError("网络错误,请重试");
} finally {
setBusyId(null);
loadPage({ silent: true });
}
};

const cancelClaim = async (id: string) => {
setBusyId(id);
setError(null);
// 取消认领后会离开"我认领的"列表,先本地移除。
setPostcards((list) => list.filter((p) => p.id !== id));
setDetailId(null);
try {
const res = await fetch(`/api/postcards/${id}/claim`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || "取消认领失败");
return;
}
setDetailId(null);
await loadPage();
if (!res.ok) setError(data?.error || "取消认领失败");
} catch {
setError("网络错误,请重试");
} finally {
setBusyId(null);
loadPage({ silent: true });
}
};

Expand Down Expand Up @@ -184,19 +190,17 @@ export default function MineClient({
const remove = async (id: string) => {
setBusyId(id);
setError(null);
setPostcards((list) => list.filter((p) => p.id !== id));
setDetailId(null);
try {
const res = await fetch(`/api/postcards/${id}`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || "删除失败");
return;
}
setDetailId(null);
await loadPage();
if (!res.ok) setError(data?.error || "删除失败");
} catch {
setError("网络错误,请重试");
} finally {
setBusyId(null);
loadPage({ silent: true });
}
};

Expand Down
18 changes: 10 additions & 8 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getCurrentUser } from "@/lib/auth";
import { pool } from "@/lib/db";
import { getTotalReceived, ensurePostcardHiddenColumn } from "@/lib/schema";
import { getTotalReceived, ensureSchema } from "@/lib/schema";
import Nav from "@/components/nav";
import HomeClient from "./home-client";
import type { Postcard, PostcardCounts } from "@/lib/types";
Expand All @@ -9,18 +9,20 @@ export const dynamic = "force-dynamic";
const DEFAULT_PAGE_SIZE = 21;

export default async function HomePage() {
const user = await getCurrentUser();
// Session lookup and the one-time schema/index check are independent — run
// them together instead of serially.
const [user, schema] = await Promise.all([getCurrentUser(), ensureSchema()]);
// Middleware guarantees a session, but guard anyway.
if (!user) return null;

const totalReceived = await getTotalReceived();

// 排除被认领人隐藏的明信片(广场对所有人不可见)。
const hiddenReady = await ensurePostcardHiddenColumn();
const listHidden = hiddenReady ? "where p.hidden_by_claimer is not true" : "";
const countHidden = hiddenReady ? "where hidden_by_claimer is not true" : "";
const listHidden = schema.hidden ? "where p.hidden_by_claimer is not true" : "";
const countHidden = schema.hidden ? "where hidden_by_claimer is not true" : "";

const [result, countResult] = await Promise.all([
// Cumulative counter, plaza page, and status counts have no data dependency
// on each other — fetch all three in parallel.
const [totalReceived, result, countResult] = await Promise.all([
getTotalReceived(),
pool.query(
`
select
Expand Down
Loading