显示第 {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..6274ce7 100644
--- a/app/mine/mine-client.tsx
+++ b/app/mine/mine-client.tsx
@@ -68,6 +68,29 @@ export default function MineClient({
}
};
+ 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;
+ }
+ const patch = (list: Postcard[]) =>
+ list.map((p) => (p.id === id ? { ...p, ...data.postcard } : p));
+ setClaimedList(patch);
+ setUploadedList(patch);
+ } catch {
+ setError("网络错误,请重试");
+ } finally {
+ setBusyId(null);
+ }
+ };
+
const remove = async (id: string) => {
setBusyId(id);
setError(null);
@@ -186,6 +209,8 @@ export default function MineClient({
onReceive={receive}
onCancelClaim={cancelClaim}
onCancelReceive={(id) => receiveWithMethod(id, "DELETE")}
+ onHide={(id) => toggleHide(id, true)}
+ onUnhide={(id) => toggleHide(id, false)}
onUpdate={update}
onDelete={remove}
onClose={() => setDetailId(null)}
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/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..8e75b5e 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 && (