diff --git a/app/api/postcards/[id]/claim/route.ts b/app/api/postcards/[id]/claim/route.ts
index fd55e99..8fcb984 100644
--- a/app/api/postcards/[id]/claim/route.ts
+++ b/app/api/postcards/[id]/claim/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
+import { bumpReceivedCount, ensurePostcardHiddenColumn } from "@/lib/schema";
// POST /api/postcards/[id]/claim -> claim an available postcard
export async function POST(
@@ -43,17 +44,26 @@ export async function DELETE(
const { id } = await params;
+ // 取消认领后隐藏权归零:hidden_by_claimer 是认领人专属的隐私开关,
+ // 认领关系解除后明信片重新回到广场公开池,不应该继续隐藏。
+ const hiddenReady = await ensurePostcardHiddenColumn();
+ const resetHidden = hiddenReady ? ", hidden_by_claimer = false" : "";
+
+ // 捕获取消前的状态:若原本是 received,取消认领同时撤销了签收,需 -1。
const result = await pool.query(
`
- update postcards
+ with target as (
+ select id, status from postcards
+ where id = $1 and claimer_id = $2 and status in ('claimed', 'received')
+ )
+ update postcards p
set status = 'available',
claimer_id = null,
claimed_at = null,
- received_at = null
- where id = $1
- and claimer_id = $2
- and status in ('claimed', 'received')
- returning *
+ received_at = null${resetHidden}
+ from target
+ where p.id = target.id
+ returning p.*, target.status as prev_status
`,
[id, user.id]
);
@@ -65,5 +75,11 @@ export async function DELETE(
);
}
- return NextResponse.json({ postcard: result.rows[0] });
+ if (result.rows[0].prev_status === "received") {
+ await bumpReceivedCount(-1); // 取消认领时若已签收,累计 -1
+ }
+
+ const { prev_status, ...postcard } = result.rows[0];
+ void prev_status;
+ return NextResponse.json({ postcard });
}
diff --git a/app/api/postcards/[id]/hide/route.ts b/app/api/postcards/[id]/hide/route.ts
new file mode 100644
index 0000000..4ce9070
--- /dev/null
+++ b/app/api/postcards/[id]/hide/route.ts
@@ -0,0 +1,65 @@
+import { NextResponse } from "next/server";
+import { pool } from "@/lib/db";
+import { getCurrentUser } from "@/lib/auth";
+import { ensurePostcardHiddenColumn } from "@/lib/schema";
+
+// 认领人对自己认领/已收到的明信片切换「隐藏」。
+// 隐藏后广场对所有人不可见,认领人仍能在「我的」看到。
+async function setHidden(
+ id: string,
+ userId: string,
+ hidden: boolean
+): Promise<{ ok: boolean; postcard?: unknown }> {
+ const ready = await ensurePostcardHiddenColumn();
+ if (!ready) return { ok: false };
+
+ const result = await pool.query(
+ `
+ update postcards
+ set hidden_by_claimer = $3
+ where id = $1 and claimer_id = $2 and status in ('claimed', 'received')
+ returning *
+ `,
+ [id, userId, hidden]
+ );
+ if (result.rows.length === 0) return { ok: false };
+ return { ok: true, postcard: result.rows[0] };
+}
+
+// POST -> 隐藏
+export async function POST(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const user = await getCurrentUser();
+ if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 });
+
+ const { id } = await params;
+ const { ok, postcard } = await setHidden(id, user.id, true);
+ if (!ok) {
+ return NextResponse.json(
+ { error: "隐藏失败:只有认领人可隐藏自己认领的明信片" },
+ { status: 409 }
+ );
+ }
+ return NextResponse.json({ postcard });
+}
+
+// DELETE -> 取消隐藏
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const user = await getCurrentUser();
+ if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 });
+
+ const { id } = await params;
+ const { ok, postcard } = await setHidden(id, user.id, false);
+ if (!ok) {
+ return NextResponse.json(
+ { error: "取消隐藏失败:只有认领人可操作" },
+ { status: 409 }
+ );
+ }
+ return NextResponse.json({ postcard });
+}
diff --git a/app/api/postcards/[id]/receive/route.ts b/app/api/postcards/[id]/receive/route.ts
index ee518c4..901a759 100644
--- a/app/api/postcards/[id]/receive/route.ts
+++ b/app/api/postcards/[id]/receive/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
+import { bumpReceivedCount } from "@/lib/schema";
// POST /api/postcards/[id]/receive -> confirm receipt (claimer only)
export async function POST(
@@ -29,6 +30,7 @@ export async function POST(
);
}
+ await bumpReceivedCount(1); // 累计签收 +1
return NextResponse.json({ postcard: result.rows[0] });
}
@@ -59,5 +61,6 @@ export async function DELETE(
);
}
+ await bumpReceivedCount(-1); // 取消签收 -1
return NextResponse.json({ postcard: result.rows[0] });
}
diff --git a/app/api/postcards/route.ts b/app/api/postcards/route.ts
index ecd0801..ea69b15 100644
--- a/app/api/postcards/route.ts
+++ b/app/api/postcards/route.ts
@@ -2,12 +2,13 @@ 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 } from "@/lib/schema";
+import { ensurePostcardMetadataColumns, ensurePostcardHiddenColumn } 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
+// GET /api/postcards -> plaza, all postcards (newest first)
+// GET /api/postcards?status=available -> plaza, filtered by status
+// GET /api/postcards?scope=claimed -> "我的" 里我认领的明信片(分页)
+// GET /api/postcards?scope=uploaded -> "我的" 里我上传的明信片(分页)
function positiveInt(value: string | null, fallback: number) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
@@ -19,18 +20,70 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const status = searchParams.get("status");
- const mine = searchParams.get("mine");
+ const scope = searchParams.get("scope"); // "claimed" | "uploaded" | null (plaza)
const page = positiveInt(searchParams.get("page"), 1);
const pageSize = Math.min(100, positiveInt(searchParams.get("pageSize"), 21));
const offset = (page - 1) * pageSize;
+ if (scope === "claimed" || scope === "uploaded") {
+ const orderBy =
+ scope === "claimed"
+ ? "p.claimed_at desc nulls last, p.created_at desc"
+ : "p.created_at desc";
+ const conditions = [`p.${scope === "claimed" ? "claimer_id" : "uploader_id"} = $1`];
+ const params: unknown[] = [user.id];
+ if (status && ["available", "claimed", "received"].includes(status)) {
+ params.push(status);
+ conditions.push(`p.status = $${params.length}`);
+ }
+ const where = `where ${conditions.join(" and ")}`;
+ params.push(pageSize, offset);
+ const limitParam = params.length - 1;
+ const offsetParam = params.length;
+
+ const [result, claimedCount, uploadedCount] = 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 ${orderBy}
+ limit $${limitParam} offset $${offsetParam}
+ `,
+ params
+ ),
+ pool.query("select count(*)::int as count from postcards where claimer_id = $1", [
+ user.id,
+ ]),
+ pool.query("select count(*)::int as count from postcards where uploader_id = $1", [
+ user.id,
+ ]),
+ ]);
+
+ const scopeCounts = {
+ claimed: claimedCount.rows[0]?.count ?? 0,
+ uploaded: uploadedCount.rows[0]?.count ?? 0,
+ };
+ const total = scopeCounts[scope];
+
+ return NextResponse.json({
+ postcards: result.rows,
+ scopeCounts,
+ pagination: { page, pageSize, total },
+ currentUserId: user.id,
+ });
+ }
+
+ // 广场视图:排除被认领人隐藏的明信片。
const baseConditions: string[] = [];
const baseParams: unknown[] = [];
-
- if (mine === "1") {
- baseParams.push(user.id);
- baseConditions.push(`p.claimer_id = $${baseParams.length}`);
- }
+ const hiddenReady = await ensurePostcardHiddenColumn();
+ if (hiddenReady) baseConditions.push("p.hidden_by_claimer is not true");
const conditions = [...baseConditions];
const params = [...baseParams];
diff --git a/app/help/page.tsx b/app/help/page.tsx
new file mode 100644
index 0000000..a53d4ab
--- /dev/null
+++ b/app/help/page.tsx
@@ -0,0 +1,20 @@
+import { readFile } from "fs/promises";
+import path from "path";
+import Nav from "@/components/nav";
+import { renderMarkdown } from "@/lib/markdown";
+
+export const dynamic = "force-dynamic";
+
+export default async function HelpPage() {
+ const filePath = path.join(process.cwd(), "content", "help.md");
+ const markdown = await readFile(filePath, "utf8");
+
+ return (
+ <>
+
+
+ {renderMarkdown(markdown)}
+
+ >
+ );
+}
diff --git a/app/home-client.tsx b/app/home-client.tsx
index ad221e3..043e0ea 100644
--- a/app/home-client.tsx
+++ b/app/home-client.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import PostcardCard from "@/components/postcard-card";
import PostcardDetail from "@/components/postcard-detail";
import type {
@@ -48,6 +48,10 @@ export default function HomeClient({
const [pageSize, setPageSize] = useState(initialPageSize);
const [page, setPage] = useState(1);
const [loadingPage, setLoadingPage] = useState(false);
+ // 点击筛选后立即高亮(乐观),无需等待请求返回。
+ const [pendingFilter, setPendingFilter] = useState(null);
+ const gridRef = useRef(null);
+ const activeFilter = pendingFilter ?? filter;
const total = counts[filter];
const pageCount = Math.max(1, Math.ceil(total / pageSize));
@@ -105,10 +109,15 @@ export default function HomeClient({
setPage(safePage);
setCounts(nextCounts);
setPostcards(data.postcards || []);
+ // 换页/换板块后把列表顶部滚回视野,避免停在旧的滚动位置。
+ requestAnimationFrame(() =>
+ gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }),
+ );
} catch {
setError("网络错误,请重试");
} finally {
setLoadingPage(false);
+ setPendingFilter(null);
}
};
@@ -190,11 +199,14 @@ export default function HomeClient({
{FILTERS.map((f) => (
)}
- {total === 0 ? (
-
- ) : (
-
- {postcards.map((p) => (
-
act(id, "claim")}
- onReceive={(id) => act(id, "receive")}
- onCancelClaim={(id) => actWithMethod(id, "claim", "DELETE")}
- onCancelReceive={(id) => actWithMethod(id, "receive", "DELETE")}
- onOpen={setDetailId}
- />
- ))}
-
- )}
+
+ {total === 0 ? (
+
+ ) : (
+
+ {postcards.map((p) => (
+
act(id, "claim")}
+ onReceive={(id) => act(id, "receive")}
+ onCancelClaim={(id) => actWithMethod(id, "claim", "DELETE")}
+ onCancelReceive={(id) => actWithMethod(id, "receive", "DELETE")}
+ onOpen={setDetailId}
+ />
+ ))}
+
+ )}
+
+ {/* 醒目的加载指示:固定在视窗顶部居中,无论滚动到哪都能看见 */}
+ {loadingPage && (
+
+
+ 加载中…
+
+ )}
+
{detail && (
act(id, "receive")}
onCancelClaim={(id) => actWithMethod(id, "claim", "DELETE")}
onCancelReceive={(id) => actWithMethod(id, "receive", "DELETE")}
+ onHide={(id) => actWithMethod(id, "hide", "POST")}
+ onUnhide={(id) => actWithMethod(id, "hide", "DELETE")}
onUpdate={update}
onDelete={remove}
onClose={() => setDetailId(null)}
@@ -253,7 +285,6 @@ export default function HomeClient({
显示第 {total === 0 ? 0 : pageStart + 1}-{pageEnd} 张,共 {total} 张
- {loadingPage ? ",加载中…" : ""}
diff --git a/app/login/page.tsx b/app/login/page.tsx
index 9d1674f..2f081d1 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -1,69 +1,11 @@
-"use client";
-
import Link from "next/link";
-import {
- CODE_VERIFIER_KEY,
- STATE_KEY,
- writePkceFallbackCookie,
-} from "@/lib/pkce-storage";
-
-function base64UrlEncode(buffer: ArrayBuffer) {
- const bytes = new Uint8Array(buffer);
- let binary = "";
- for (const byte of bytes) binary += String.fromCharCode(byte);
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
-}
-
-function randomString(length = 43) {
- const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
- const array = new Uint8Array(length);
- crypto.getRandomValues(array);
- return Array.from(array, (x) => chars[x % chars.length]).join("");
-}
-
-async function sha256(input: string) {
- const data = new TextEncoder().encode(input);
- return crypto.subtle.digest("SHA-256", data);
-}
+import { getTotalReceived } from "@/lib/schema";
+import LoginButton from "@/components/login-button";
-export default function LoginPage() {
- const handleLogin = async () => {
- const clientId = process.env.NEXT_PUBLIC_CASDOOR_CLIENT_ID;
- const serverUrl = process.env.NEXT_PUBLIC_CASDOOR_SERVER_URL;
- const redirectUri =
- process.env.NEXT_PUBLIC_CASDOOR_REDIRECT_URI ||
- `${window.location.origin}/auth/callback`;
- const signinUrl =
- process.env.NEXT_PUBLIC_CASDOOR_SIGNIN_URL ||
- (serverUrl ? `${serverUrl.replace(/\/+$/, "")}/login/oauth/authorize` : "");
- const scope = process.env.NEXT_PUBLIC_CASDOOR_SCOPE || "openid profile email";
+export const dynamic = "force-dynamic";
- if (!clientId || !signinUrl) {
- alert("缺少 Casdoor 配置,请检查环境变量。");
- return;
- }
-
- const state = randomString(32);
- const verifier = randomString(64);
- const challenge = base64UrlEncode(await sha256(verifier));
-
- sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
- sessionStorage.setItem(STATE_KEY, state);
- writePkceFallbackCookie(CODE_VERIFIER_KEY, verifier);
- writePkceFallbackCookie(STATE_KEY, state);
-
- const params = new URLSearchParams({
- response_type: "code",
- client_id: clientId,
- redirect_uri: redirectUri,
- scope,
- state,
- code_challenge: challenge,
- code_challenge_method: "S256",
- });
-
- window.location.assign(`${signinUrl}?${params.toString()}`);
- };
+export default async function LoginPage() {
+ const totalReceived = await getTotalReceived();
return (
@@ -79,13 +21,11 @@ export default function LoginPage() {
ShanghaiTech民间明信片认领互助站
+
+ 📮 已累计签收 {totalReceived} 张明信片
+
-
+
);
}
diff --git a/app/mine/mine-client.tsx b/app/mine/mine-client.tsx
index 6040de5..adc0681 100644
--- a/app/mine/mine-client.tsx
+++ b/app/mine/mine-client.tsx
@@ -1,25 +1,121 @@
"use client";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import PostcardCard from "@/components/postcard-card";
import PostcardDetail from "@/components/postcard-detail";
import type { Postcard, PostcardUpdateInput } from "@/lib/types";
+type Scope = "claimed" | "uploaded";
+type ScopeCounts = Record
;
+
+const TABS: { key: Scope; label: string }[] = [
+ { key: "claimed", label: "我认领的" },
+ { key: "uploaded", label: "我上传的" },
+];
+
+type PostcardsPageResponse = {
+ postcards?: Postcard[];
+ scopeCounts?: ScopeCounts;
+ pagination?: {
+ page?: number;
+ pageSize?: number;
+ total?: number;
+ };
+ error?: string;
+};
+
export default function MineClient({
- claimed,
- uploaded,
+ initialClaimed,
+ initialScopeCounts,
+ initialPageSize,
currentUserId,
}: {
- claimed: Postcard[];
- uploaded: Postcard[];
+ initialClaimed: Postcard[];
+ initialScopeCounts: ScopeCounts;
+ initialPageSize: number;
currentUserId: string;
}) {
- const [tab, setTab] = useState<"claimed" | "uploaded">("claimed");
- const [claimedList, setClaimedList] = useState(claimed);
- const [uploadedList, setUploadedList] = useState(uploaded);
+ const [scope, setScope] = useState("claimed");
+ const [postcards, setPostcards] = useState(initialClaimed);
+ const [scopeCounts, setScopeCounts] = useState(initialScopeCounts);
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);
+ // 切换 tab 后立即高亮(乐观),无需等待请求返回,和广场筛选一致。
+ const [pendingScope, setPendingScope] = useState(null);
+ const gridRef = useRef(null);
+ const activeScope = pendingScope ?? scope;
+
+ const total = scopeCounts[scope];
+ 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);
+
+ useEffect(() => {
+ setPage((current) => Math.min(current, pageCount));
+ }, [pageCount]);
+
+ const detail = useMemo(
+ () => postcards.find((p) => p.id === detailId) ?? null,
+ [postcards, detailId],
+ );
+
+ const loadPage = async ({
+ nextScope = scope,
+ nextPage = currentPage,
+ nextPageSize = pageSize,
+ }: {
+ nextScope?: Scope;
+ nextPage?: number;
+ nextPageSize?: number;
+ } = {}) => {
+ setLoadingPage(true);
+ setError(null);
+ try {
+ const params = new URLSearchParams({
+ scope: nextScope,
+ page: String(nextPage),
+ pageSize: String(nextPageSize),
+ });
+
+ 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.scopeCounts || scopeCounts;
+ const nextTotal = nextCounts[nextScope];
+ const nextPageCount = Math.max(1, Math.ceil(nextTotal / nextPageSize));
+ const safePage = Math.min(nextPage, nextPageCount);
+ if (safePage !== nextPage) {
+ await loadPage({ nextScope, nextPage: safePage, nextPageSize });
+ return;
+ }
+
+ setScope(nextScope);
+ setPageSize(nextPageSize);
+ setPage(safePage);
+ setScopeCounts(nextCounts);
+ setPostcards(data.postcards || []);
+ // 换页/换 tab 后把列表顶部滚回视野,避免停在旧的滚动位置。
+ requestAnimationFrame(() =>
+ gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }),
+ );
+ } catch {
+ setError("网络错误,请重试");
+ } finally {
+ setLoadingPage(false);
+ setPendingScope(null);
+ }
+ };
const receive = async (id: string) => {
await receiveWithMethod(id, "POST");
@@ -35,10 +131,8 @@ export default function MineClient({
setError(data?.error || "操作失败");
return;
}
- const patch = (list: Postcard[]) =>
- list.map((p) => (p.id === id ? { ...p, ...data.postcard } : p));
- setClaimedList(patch);
- setUploadedList(patch);
+ setDetailId(null);
+ await loadPage();
} catch {
setError("网络错误,请重试");
} finally {
@@ -56,11 +150,30 @@ export default function MineClient({
setError(data?.error || "取消认领失败");
return;
}
- setClaimedList((list) => list.filter((p) => p.id !== id));
- setUploadedList((list) =>
- list.map((p) => (p.id === id ? { ...p, ...data.postcard } : p))
- );
setDetailId(null);
+ await loadPage();
+ } catch {
+ setError("网络错误,请重试");
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ const toggleHide = async (id: string, hidden: boolean) => {
+ setBusyId(id);
+ setError(null);
+ try {
+ const res = await fetch(`/api/postcards/${id}/hide`, {
+ method: hidden ? "POST" : "DELETE",
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError(data?.error || (hidden ? "隐藏失败" : "取消隐藏失败"));
+ return;
+ }
+ setPostcards((list) =>
+ list.map((p) => (p.id === id ? { ...p, ...data.postcard } : p)),
+ );
} catch {
setError("网络错误,请重试");
} finally {
@@ -78,10 +191,8 @@ export default function MineClient({
setError(data?.error || "删除失败");
return;
}
- const drop = (list: Postcard[]) => list.filter((p) => p.id !== id);
- setClaimedList(drop);
- setUploadedList(drop);
setDetailId(null);
+ await loadPage();
} catch {
setError("网络错误,请重试");
} finally {
@@ -103,10 +214,9 @@ export default function MineClient({
setError(data?.error || "保存失败");
return false;
}
- const patch = (list: Postcard[]) =>
- list.map((p) => (p.id === id ? { ...p, ...data.postcard } : p));
- setClaimedList(patch);
- setUploadedList(patch);
+ setPostcards((list) =>
+ list.map((p) => (p.id === id ? { ...p, ...data.postcard } : p)),
+ );
return true;
} catch {
setError("网络错误,请重试");
@@ -116,37 +226,27 @@ export default function MineClient({
}
};
- const list = tab === "claimed" ? claimedList : uploadedList;
-
- const detail = useMemo(
- () =>
- [...claimedList, ...uploadedList].find((p) => p.id === detailId) ?? null,
- [claimedList, uploadedList, detailId]
- );
-
return (
-
-
-
+
+ {TABS.map((t) => (
+
+ ))}
{error && (
@@ -155,28 +255,46 @@ export default function MineClient({
)}
- {list.length === 0 ? (
-
- {tab === "claimed" ? "你还没有认领任何明信片。" : "你还没有上传任何明信片。"}
-
- ) : (
-
- {list.map((p) => (
-
receiveWithMethod(id, "DELETE") : undefined
- }
- onOpen={setDetailId}
- />
- ))}
-
- )}
+
+ {total === 0 ? (
+
+ {scope === "claimed" ? "你还没有认领任何明信片。" : "你还没有上传任何明信片。"}
+
+ ) : (
+
+ {postcards.map((p) => (
+
receiveWithMethod(id, "DELETE") : undefined
+ }
+ onOpen={setDetailId}
+ />
+ ))}
+
+ )}
+
+ {/* 醒目的加载指示:固定在视窗顶部居中,和广场筛选切换保持一致 */}
+ {loadingPage && (
+
+
+ 加载中…
+
+ )}
+
{detail && (
receiveWithMethod(id, "DELETE")}
+ onHide={(id) => toggleHide(id, true)}
+ onUnhide={(id) => toggleHide(id, false)}
onUpdate={update}
onDelete={remove}
onClose={() => setDetailId(null)}
/>
)}
+
+
+
+ 显示第 {total === 0 ? 0 : pageStart + 1}-{pageEnd} 张,共 {total} 张
+
+
+
+
+
+ {currentPage}/{pageCount}
+
+
+
+
+
+
);
}
diff --git a/app/mine/page.tsx b/app/mine/page.tsx
index fcf1c9c..596eccf 100644
--- a/app/mine/page.tsx
+++ b/app/mine/page.tsx
@@ -5,12 +5,13 @@ import MineClient from "./mine-client";
import type { Postcard } from "@/lib/types";
export const dynamic = "force-dynamic";
+const DEFAULT_PAGE_SIZE = 21;
export default async function MinePage() {
const user = await getCurrentUser();
if (!user) return null;
- const [claimed, uploaded] = await Promise.all([
+ const [claimedPage, claimedCount, uploadedCount] = await Promise.all([
pool.query(
`
select p.*, cl.nickname as claimer_nickname
@@ -18,19 +19,16 @@ export default async function MinePage() {
left join users cl on p.claimer_id = cl.id
where p.claimer_id = $1
order by p.claimed_at desc nulls last, p.created_at desc
+ limit $2
`,
- [user.id]
- ),
- pool.query(
- `
- select p.*, cl.nickname as claimer_nickname
- from postcards p
- left join users cl on p.claimer_id = cl.id
- where p.uploader_id = $1
- order by p.created_at desc
- `,
- [user.id]
+ [user.id, DEFAULT_PAGE_SIZE]
),
+ pool.query("select count(*)::int as count from postcards where claimer_id = $1", [
+ user.id,
+ ]),
+ pool.query("select count(*)::int as count from postcards where uploader_id = $1", [
+ user.id,
+ ]),
]);
return (
@@ -39,8 +37,12 @@ export default async function MinePage() {
我的明信片
diff --git a/app/page.tsx b/app/page.tsx
index b9838a1..17093c4 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,5 +1,6 @@
import { getCurrentUser } from "@/lib/auth";
import { pool } from "@/lib/db";
+import { getTotalReceived, ensurePostcardHiddenColumn } from "@/lib/schema";
import Nav from "@/components/nav";
import HomeClient from "./home-client";
import type { Postcard, PostcardCounts } from "@/lib/types";
@@ -12,6 +13,13 @@ export default async function HomePage() {
// 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 [result, countResult] = await Promise.all([
pool.query(
`
@@ -22,12 +30,15 @@ export default async function HomePage() {
from postcards p
left join users up on p.uploader_id = up.id
left join users cl on p.claimer_id = cl.id
+ ${listHidden}
order by p.created_at desc
limit $1
`,
[DEFAULT_PAGE_SIZE]
),
- pool.query("select status, count(*)::int as count from postcards group by status"),
+ pool.query(
+ `select status, count(*)::int as count from postcards ${countHidden} group by status`
+ ),
]);
const initialCounts: PostcardCounts = {
@@ -52,6 +63,9 @@ export default async function HomePage() {
↗️网页右上角点击昵称,进入个人页面,配置常用收件名后可自动匹配 取到明信片后记得确认签收~
+
+ 📮 已累计签收 {totalReceived} 张明信片
+
chars[x % chars.length]).join("");
+}
+
+async function sha256(input: string) {
+ const data = new TextEncoder().encode(input);
+ return crypto.subtle.digest("SHA-256", data);
+}
+
+export default function LoginButton() {
+ const handleLogin = async () => {
+ const clientId = process.env.NEXT_PUBLIC_CASDOOR_CLIENT_ID;
+ const serverUrl = process.env.NEXT_PUBLIC_CASDOOR_SERVER_URL;
+ const redirectUri =
+ process.env.NEXT_PUBLIC_CASDOOR_REDIRECT_URI ||
+ `${window.location.origin}/auth/callback`;
+ const signinUrl =
+ process.env.NEXT_PUBLIC_CASDOOR_SIGNIN_URL ||
+ (serverUrl ? `${serverUrl.replace(/\/+$/, "")}/login/oauth/authorize` : "");
+ const scope = process.env.NEXT_PUBLIC_CASDOOR_SCOPE || "openid profile email";
+
+ if (!clientId || !signinUrl) {
+ alert("缺少 Casdoor 配置,请检查环境变量。");
+ return;
+ }
+
+ const state = randomString(32);
+ const verifier = randomString(64);
+ const challenge = base64UrlEncode(await sha256(verifier));
+
+ sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
+ sessionStorage.setItem(STATE_KEY, state);
+ writePkceFallbackCookie(CODE_VERIFIER_KEY, verifier);
+ writePkceFallbackCookie(STATE_KEY, state);
+
+ const params = new URLSearchParams({
+ response_type: "code",
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ scope,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: "S256",
+ });
+
+ window.location.assign(`${signinUrl}?${params.toString()}`);
+ };
+
+ return (
+
+ );
+}
diff --git a/components/nav-bar.tsx b/components/nav-bar.tsx
new file mode 100644
index 0000000..b767201
--- /dev/null
+++ b/components/nav-bar.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { useTransition, type MouseEvent } from "react";
+import LogoutButton from "./logout-button";
+
+const NAV_ITEMS = [
+ { href: "/", label: "广场" },
+ { href: "/upload", label: "上传" },
+ { href: "/mine", label: "我的" },
+ { href: "/help", label: "帮助" },
+ { href: "/about", label: "关于" },
+];
+
+export default function NavBar({
+ loggedIn,
+ nickname,
+}: {
+ loggedIn: boolean;
+ nickname: string | null;
+}) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+
+ // 顶部导航切换页面时,和"全部/待认领/…"筛选一样:立即反馈 + 顶部加载指示,
+ // 避免用户在慢网络下点了没反应、又重复点击。
+ const navigate = (href: string) => (e: MouseEvent) => {
+ if (href === pathname) {
+ e.preventDefault();
+ return;
+ }
+ e.preventDefault();
+ startTransition(() => {
+ router.push(href);
+ });
+ };
+
+ const linkClass = (href: string) =>
+ `shrink-0 whitespace-nowrap rounded-md px-2.5 py-2 transition sm:px-3 ${
+ pathname === href
+ ? "bg-secondary font-medium text-foreground"
+ : "text-muted-foreground hover:bg-secondary hover:text-foreground"
+ }`;
+
+ return (
+
+ );
+}
diff --git a/components/nav.tsx b/components/nav.tsx
index 6b6d297..4d92784 100644
--- a/components/nav.tsx
+++ b/components/nav.tsx
@@ -1,6 +1,6 @@
import Link from "next/link";
import { getCurrentUser } from "@/lib/auth";
-import LogoutButton from "./logout-button";
+import NavBar from "./nav-bar";
export default async function Nav() {
const user = await getCurrentUser();
@@ -12,43 +12,7 @@ export default async function Nav() {
✉️
PostBack
-
+
);
diff --git a/components/postcard-card.tsx b/components/postcard-card.tsx
index 81ca60b..2833864 100644
--- a/components/postcard-card.tsx
+++ b/components/postcard-card.tsx
@@ -73,6 +73,9 @@ export default function PostcardCard({
{postcard.pickup_location && (
取件地点:{postcard.pickup_location}
)}
+ {postcard.hidden_by_claimer && (
+
🙈 已隐藏 · 仅自己可见
+ )}
{postcard.status === "available" && onClaim && (
diff --git a/components/postcard-detail.tsx b/components/postcard-detail.tsx
index 0bc4bc0..91b5174 100644
--- a/components/postcard-detail.tsx
+++ b/components/postcard-detail.tsx
@@ -62,6 +62,8 @@ export default function PostcardDetail({
onReceive,
onCancelClaim,
onCancelReceive,
+ onHide,
+ onUnhide,
onUpdate,
onDelete,
onClose,
@@ -73,6 +75,8 @@ export default function PostcardDetail({
onReceive?: (id: string) => void;
onCancelClaim?: (id: string) => void;
onCancelReceive?: (id: string) => void;
+ onHide?: (id: string) => void;
+ onUnhide?: (id: string) => void;
onUpdate?: (id: string, input: PostcardUpdateInput) => boolean | Promise
;
onDelete?: (id: string) => void;
onClose: () => void;
@@ -87,6 +91,10 @@ export default function PostcardDetail({
const canEdit = isUploader && !!onUpdate;
const canDelete =
(isUploader || (isClaimer && postcard.status === "received")) && !!onDelete;
+ const canHideToggle =
+ isClaimer &&
+ (postcard.status === "claimed" || postcard.status === "received") &&
+ (!!onHide || !!onUnhide);
useEffect(() => {
setConfirmDelete(false);
@@ -260,6 +268,12 @@ export default function PostcardDetail({
{postcard.recipient_name}
+ {postcard.hidden_by_claimer && (
+
+ 已隐藏 · 仅自己可见(不在广场展示)
+
+ )}
+
{postcard.note && (
{postcard.note}
@@ -331,6 +345,28 @@ export default function PostcardDetail({
)}
+ {!editing && canHideToggle && (
+ postcard.hidden_by_claimer
+ ? onUnhide && (
+
+ )
+ : onHide && (
+
+ )
+ )}
+
{canEdit && !editing && (