From 62aac7b65b18e3154a035b5af5833ed7245e20db Mon Sep 17 00:00:00 2001 From: AlexWei2020 Date: Wed, 8 Jul 2026 13:55:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E7=B4=AF=E8=AE=A1=E7=AD=BE?= =?UTF-8?q?=E6=94=B6=E8=AE=A1=E6=95=B0=E3=80=81=E8=AE=A4=E9=A2=86=E4=BA=BA?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E8=AE=B0=E5=BD=95=E3=80=81=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 全站累计签收计数:新增单行表 site_stats(total_received),首次以当前 received 数量为基线。签收 +1、取消签收 -1、取消认领(原已签收)时 -1, 删除记录不减(保护隐私)。首页与登录页显示已累计签收 N 张明信片; 登录页改为服务端组件 + 客户端 LoginButton,直读计数无闪烁。 2. 认领人隐藏记录(仅自己可见):postcards 加 hidden_by_claimer;新增 /api/postcards/[id]/hide POST/DELETE(仅认领人、claimed/received)。 广场列表与计数排除隐藏项,认领人仍能在「我的」看到并含已隐藏标记; 详情弹窗加隐藏/取消隐藏按钮。 3. 加载体验:换页/换板块时列表变暗 + 视窗顶部居中显示醒目加载中指示, 点击筛选立即高亮(乐观),加载完成平滑滚回列表顶部;移除原右下角文字。 init.sql 同步新增 hidden_by_claimer 列、site_stats 表与索引(幂等迁移)。 --- app/api/postcards/[id]/claim/route.ts | 23 +++++-- app/api/postcards/[id]/hide/route.ts | 65 ++++++++++++++++++ app/api/postcards/[id]/receive/route.ts | 3 + app/api/postcards/route.ts | 6 +- app/home-client.tsx | 89 +++++++++++++++++-------- app/login/page.tsx | 78 +++------------------- app/mine/mine-client.tsx | 25 +++++++ app/page.tsx | 16 ++++- components/login-button.tsx | 75 +++++++++++++++++++++ components/postcard-card.tsx | 3 + components/postcard-detail.tsx | 36 ++++++++++ lib/schema.ts | 66 ++++++++++++++++++ lib/types.ts | 1 + scripts/init.sql | 13 ++++ 14 files changed, 393 insertions(+), 106 deletions(-) create mode 100644 app/api/postcards/[id]/hide/route.ts create mode 100644 components/login-button.tsx diff --git a/app/api/postcards/[id]/claim/route.ts b/app/api/postcards/[id]/claim/route.ts index fd55e99..365a431 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 } from "@/lib/schema"; // POST /api/postcards/[id]/claim -> claim an available postcard export async function POST( @@ -43,17 +44,21 @@ export async function DELETE( const { id } = await params; + // 捕获取消前的状态:若原本是 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 * + from target + where p.id = target.id + returning p.*, target.status as prev_status `, [id, user.id] ); @@ -65,5 +70,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..4a456dc 100644 --- a/app/api/postcards/route.ts +++ b/app/api/postcards/route.ts @@ -2,7 +2,7 @@ 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) @@ -30,6 +30,10 @@ export async function GET(request: Request) { if (mine === "1") { baseParams.push(user.id); baseConditions.push(`p.claimer_id = $${baseParams.length}`); + } else { + // 广场视图:排除被认领人隐藏的明信片。 + const hiddenReady = await ensurePostcardHiddenColumn(); + if (hiddenReady) baseConditions.push("p.hidden_by_claimer is not true"); } const conditions = [...baseConditions]; 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) => ( + ); } 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 && ( - +

+ {TABS.map((t) => ( + + ))}
{error && ( @@ -178,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 && ( 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/package.json b/package.json index 5d69216..1ccc2ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postback", - "version": "0.1.1", + "version": "0.1.2", "private": true, "scripts": { "dev": "next dev",