feat: 撤回滑动功能,按钮移至进度条旁
- 新增 POST /api/room/[id]/undo 端点,撤回 like 和 swipeCount - 进度条右侧显示"↩ 撤回"按钮,可一直撤回到第一张 - ActionButtons 恢复干净的两按钮布局,避免误触
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { userId, restaurantId } = await req.json();
|
||||||
|
|
||||||
|
if (!userId || restaurantId == null) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "userId and restaurantId are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rid = String(restaurantId);
|
||||||
|
|
||||||
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
|
if (data.likes[rid]) {
|
||||||
|
data.likes[rid] = data.likes[rid].filter((u) => u !== userId);
|
||||||
|
if (data.likes[rid].length === 0) {
|
||||||
|
delete data.likes[rid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.match === rid) {
|
||||||
|
data.match = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = data.swipeCounts[userId] ?? 0;
|
||||||
|
if (count > 0) {
|
||||||
|
data.swipeCounts[userId] = count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "房间不存在或已过期" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to undo swipe:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "撤回失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import ActionButtons from "./ActionButtons";
|
|||||||
import MatchResult from "./MatchResult";
|
import MatchResult from "./MatchResult";
|
||||||
import SwipeGuide from "./SwipeGuide";
|
import SwipeGuide from "./SwipeGuide";
|
||||||
import { Restaurant, SwipeDirection, MatchType } from "@/types";
|
import { Restaurant, SwipeDirection, MatchType } from "@/types";
|
||||||
import { Heart } from "lucide-react";
|
import { Heart, Undo2 } from "lucide-react";
|
||||||
|
|
||||||
interface SwipeDeckProps {
|
interface SwipeDeckProps {
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
@@ -38,6 +38,7 @@ export default function SwipeDeck({
|
|||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
const [bubble, setBubble] = useState("");
|
const [bubble, setBubble] = useState("");
|
||||||
const [guideVisible, setGuideVisible] = useState(true);
|
const [guideVisible, setGuideVisible] = useState(true);
|
||||||
|
const [swipeHistory, setSwipeHistory] = useState<string[]>([]);
|
||||||
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
|
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
|
||||||
const swipingRef = useRef(false);
|
const swipingRef = useRef(false);
|
||||||
const prevLikeCounts = useRef<Record<string, number>>({});
|
const prevLikeCounts = useRef<Record<string, number>>({});
|
||||||
@@ -104,6 +105,7 @@ export default function SwipeDeck({
|
|||||||
const action = direction === "right" ? "like" : "nope";
|
const action = direction === "right" ? "like" : "nope";
|
||||||
sendSwipe(current.id, action);
|
sendSwipe(current.id, action);
|
||||||
|
|
||||||
|
setSwipeHistory((h) => [...h, current.id]);
|
||||||
const nextIndex = currentIndex + 1;
|
const nextIndex = currentIndex + 1;
|
||||||
setCurrentIndex(nextIndex);
|
setCurrentIndex(nextIndex);
|
||||||
swipeFnRef.current = null;
|
swipeFnRef.current = null;
|
||||||
@@ -122,6 +124,27 @@ export default function SwipeDeck({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(async () => {
|
||||||
|
if (swipeHistory.length === 0 || currentIndex === 0) return;
|
||||||
|
|
||||||
|
const lastRid = swipeHistory[swipeHistory.length - 1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/room/${roomId}/undo`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, restaurantId: lastRid }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
setSwipeHistory((h) => h.slice(0, -1));
|
||||||
|
setCurrentIndex((i) => i - 1);
|
||||||
|
setLocalMatchId(null);
|
||||||
|
swipeFnRef.current = null;
|
||||||
|
}, [swipeHistory, currentIndex, roomId, userId]);
|
||||||
|
|
||||||
const handleReset = useCallback(async () => {
|
const handleReset = useCallback(async () => {
|
||||||
setResetting(true);
|
setResetting(true);
|
||||||
try {
|
try {
|
||||||
@@ -129,6 +152,7 @@ export default function SwipeDeck({
|
|||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setShowMatch(false);
|
setShowMatch(false);
|
||||||
setLocalMatchId(null);
|
setLocalMatchId(null);
|
||||||
|
setSwipeHistory([]);
|
||||||
prevLikeCounts.current = {};
|
prevLikeCounts.current = {};
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false);
|
setResetting(false);
|
||||||
@@ -147,8 +171,8 @@ export default function SwipeDeck({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!allSwiped && !resolvedMatchId && (
|
{!allSwiped && !resolvedMatchId && (
|
||||||
<div className="flex items-center justify-center gap-2 px-4 pb-1">
|
<div className="mx-auto flex w-full max-w-sm items-center gap-2 px-4 pb-1">
|
||||||
<div className="h-1 flex-1 max-w-sm overflow-hidden rounded-full bg-zinc-100">
|
<div className="h-1 flex-1 overflow-hidden rounded-full bg-zinc-100">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="h-full rounded-full bg-emerald-400"
|
className="h-full rounded-full bg-emerald-400"
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
@@ -161,6 +185,14 @@ export default function SwipeDeck({
|
|||||||
<span className="shrink-0 text-[11px] tabular-nums text-zinc-400">
|
<span className="shrink-0 text-[11px] tabular-nums text-zinc-400">
|
||||||
{currentIndex}/{restaurants.length}
|
{currentIndex}/{restaurants.length}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={currentIndex === 0}
|
||||||
|
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-amber-500 transition-colors active:bg-amber-50 disabled:opacity-0"
|
||||||
|
>
|
||||||
|
<Undo2 size={12} />
|
||||||
|
撤回
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user