refactor(P1): 5 项代码质量改进 — 消除重复、拆分巨型组件、统一基础设施
Task 4: 统一 amap.ts 为完整 API 客户端 - 扩展 amap.ts 为统一客户端(amapFetch 8s 超时 + 错误处理) - 导出 searchPlaceText/searchPlaceAround/getInputTips/reverseGeocode/getTransitDirection - 精简 4 个 location route 为单行调用,blindboxPlanGen 删除 ~80 行内联 API 代码 Task 2: 抽取 ShareCardShell 消除三兄弟重复 - 新建 ShareCardShell.tsx 共享外框/背景/品牌头/QR 底部 - RestaurantShareCard 406→268 行,BlindboxShareCard 341→173 行,BlindboxPlanShareCard 277→159 行 Task 3: 拆分 BlindboxPlan.tsx (742→371 行) - 提取 planUtils.ts (guessCategory + formatDuration) - 提取 PoiSearchField / SortablePlanItem / PlanItemEditModal 三个独立组件 Task 1: 拆分 blindbox/[code]/page.tsx 上帝组件 (1300→509 行) - 提取 useBlindboxRoom / useBlindboxIdeas / useBlindboxPlan / useBlindboxDraw 四个 hooks - 提取 BlindboxPoolPhase / BlindboxRevealPhase 两个子组件 - 主页面仅保留 phase 协调 + hook 组装 + 子组件渲染 Task 5: 统一 SWR 数据获取层 - 新建 fetcher.ts (FetchError 携带 status,401 不重试) - 新建 useBlindboxRooms / useAchievements / useFavorites SWR hooks - useRoomPolling 改用共享 fetcher - blindbox 大厅/成就/个人中心页面删除手写 fetch 样板代码 - JWT 过期时自动弹出登录框而非反复重试
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "@/lib/fetcher";
|
||||
import type { DecisionRecord, ContractRecord } from "@/types";
|
||||
|
||||
interface Stats {
|
||||
totalDecisions: number;
|
||||
totalContracts: number;
|
||||
completedContracts: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
interface AchievementsResponse {
|
||||
stats: Stats;
|
||||
decisions: DecisionRecord[];
|
||||
contracts: ContractRecord[];
|
||||
}
|
||||
|
||||
export function useAchievements(userId: string | undefined) {
|
||||
const { data, isLoading, error } = useSWR<AchievementsResponse>(
|
||||
userId ? `/api/user/achievements?userId=${userId}` : null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
return {
|
||||
stats: data?.stats ?? {
|
||||
totalDecisions: 0,
|
||||
totalContracts: 0,
|
||||
completedContracts: 0,
|
||||
completionRate: 0,
|
||||
},
|
||||
decisions: data?.decisions ?? [],
|
||||
contracts: data?.contracts ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useAnimation } from "framer-motion";
|
||||
import confetti from "canvas-confetti";
|
||||
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
||||
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
|
||||
|
||||
export function useBlindboxDraw(
|
||||
room: RoomInfo | null,
|
||||
profile: UserProfile | null,
|
||||
poolCount: number,
|
||||
setPoolCount: React.Dispatch<React.SetStateAction<number>>,
|
||||
setDrawnHistory: React.Dispatch<React.SetStateAction<DrawnIdea[]>>,
|
||||
setError: (e: string) => void,
|
||||
setPhase: (p: Phase) => void,
|
||||
) {
|
||||
const [revealedIdea, setRevealedIdea] = useState<DrawnIdea | null>(null);
|
||||
const [showShareCard, setShowShareCard] = useState(false);
|
||||
const boxControls = useAnimation();
|
||||
const confettiAliveRef = useRef(false);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const fireConfetti = useCallback(() => {
|
||||
const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"];
|
||||
confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 });
|
||||
confettiAliveRef.current = true;
|
||||
const end = Date.now() + 3000;
|
||||
const frame = () => {
|
||||
if (Date.now() > end || !confettiAliveRef.current) return;
|
||||
confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
|
||||
confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
timersRef.current.push(setTimeout(frame, 200));
|
||||
}, []);
|
||||
|
||||
const handleDraw = async () => {
|
||||
if (poolCount === 0 || !profile || !room) {
|
||||
setError("盒子是空的,先往里面塞点想法吧!");
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase("shaking");
|
||||
setError("");
|
||||
|
||||
await boxControls.start({
|
||||
rotate: [0, -8, 8, -10, 10, -12, 12, -8, 8, -4, 4, 0],
|
||||
scale: [1, 1.05, 0.95, 1.08, 0.92, 1.1, 0.9, 1.05, 0.95, 1],
|
||||
transition: { duration: 2.5, ease: "easeInOut" },
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/draw", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roomId: room.id, userId: profile.id }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "抽取失败");
|
||||
}
|
||||
|
||||
const idea = await res.json();
|
||||
setRevealedIdea(idea);
|
||||
setPhase("reveal");
|
||||
setPoolCount((c) => Math.max(0, c - 1));
|
||||
setDrawnHistory((prev) => [idea, ...prev]);
|
||||
fireConfetti();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "抽取失败");
|
||||
setPhase("pool");
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
setPhase("pool");
|
||||
setRevealedIdea(null);
|
||||
setShowShareCard(false);
|
||||
}, [setPhase]);
|
||||
|
||||
return {
|
||||
revealedIdea,
|
||||
showShareCard,
|
||||
setShowShareCard,
|
||||
boxControls,
|
||||
fireConfetti,
|
||||
handleDraw,
|
||||
handleContinue,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useAnimation } from "framer-motion";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import type { MyIdea } from "@/components/BlindboxMyIdeas";
|
||||
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
||||
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
const IDEA_INSPIRATIONS = [
|
||||
"去城市最高楼看日落",
|
||||
"挑战一人做一道菜",
|
||||
"找一家从没去过的店",
|
||||
"逛一个从没去过的街区",
|
||||
"去公园野餐",
|
||||
"看一场午夜电影",
|
||||
"在家做一顿异国料理",
|
||||
"骑车去郊外探险",
|
||||
"去二手市场淘宝",
|
||||
"去博物馆逛半天",
|
||||
"试一项没玩过的运动",
|
||||
"随机坐公交到终点站",
|
||||
"一起画画或做手工",
|
||||
"找个天台看星星",
|
||||
"去一家评分最低的餐厅",
|
||||
"穿最好看的衣服去拍照",
|
||||
"交换手机玩一个小时",
|
||||
"一起去做志愿者",
|
||||
];
|
||||
|
||||
function pickRandom<T>(arr: T[], n: number): T[] {
|
||||
const shuffled = [...arr].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, n);
|
||||
}
|
||||
|
||||
export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | null) {
|
||||
const toast = useToast();
|
||||
const boxControls = useAnimation();
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState(() => pickRandom(IDEA_INSPIRATIONS, 4));
|
||||
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
|
||||
const [suggestionsSource, setSuggestionsSource] = useState<"static" | "ai">("static");
|
||||
const [poolCount, setPoolCount] = useState(0);
|
||||
const [myIdeas, setMyIdeas] = useState<MyIdea[]>([]);
|
||||
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
|
||||
const [submitFlash, setSubmitFlash] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const fetchIdeas = useCallback(async () => {
|
||||
if (!room || !profile) return;
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${profile.id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPoolCount(data.poolCount ?? 0);
|
||||
setMyIdeas(data.myIdeas ?? []);
|
||||
setDrawnHistory(data.drawn ?? []);
|
||||
}
|
||||
} catch (e) { console.error("fetchIdeas failed:", e); }
|
||||
}, [room, profile]);
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
if (!room || !profile) return;
|
||||
setSuggestionsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}&userId=${profile.id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.suggestions?.length > 0) {
|
||||
setSuggestions(data.suggestions);
|
||||
setSuggestionsSource("ai");
|
||||
setSuggestionsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error("fetchSuggestions failed:", e); }
|
||||
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
|
||||
setSuggestionsSource("static");
|
||||
setSuggestionsLoading(false);
|
||||
}, [room, profile]);
|
||||
|
||||
const refreshSuggestions = useCallback(() => {
|
||||
if (suggestionsSource === "ai") {
|
||||
fetchSuggestions();
|
||||
} else {
|
||||
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
|
||||
}
|
||||
}, [suggestionsSource, fetchSuggestions]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || submitting || !profile || !room) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/blindbox", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roomId: room.id, userId: profile.id, content: text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "提交失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
setInput("");
|
||||
setPoolCount((c) => c + 1);
|
||||
setMyIdeas((prev) => [{
|
||||
id: data.id,
|
||||
content: text,
|
||||
createdAt: new Date().toISOString(),
|
||||
...data.tags && {
|
||||
category: data.tags.category,
|
||||
timeSlot: data.tags.timeSlot,
|
||||
estimatedMinutes: data.tags.estimatedMinutes,
|
||||
costLevel: data.tags.costLevel,
|
||||
intensity: data.tags.intensity,
|
||||
needsBooking: data.tags.needsBooking,
|
||||
searchQuery: data.tags.searchQuery,
|
||||
searchType: data.tags.searchType,
|
||||
},
|
||||
}, ...prev]);
|
||||
setSubmitFlash(true);
|
||||
timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600));
|
||||
boxControls.start({
|
||||
scale: [1, 1.08, 1],
|
||||
rotate: [0, -3, 3, 0],
|
||||
transition: { duration: 0.5 },
|
||||
});
|
||||
fetchSuggestions();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "提交失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditIdea = useCallback(async (ideaId: string, newContent: string) => {
|
||||
if (!profile) return;
|
||||
const trimmed = newContent.trim();
|
||||
if (!trimmed || trimmed.length > 200) return;
|
||||
try {
|
||||
const res = await fetch("/api/blindbox", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ideaId, userId: profile.id, content: trimmed }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "编辑失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? {
|
||||
...i,
|
||||
content: trimmed,
|
||||
...data.tags && {
|
||||
category: data.tags.category,
|
||||
timeSlot: data.tags.timeSlot,
|
||||
estimatedMinutes: data.tags.estimatedMinutes,
|
||||
costLevel: data.tags.costLevel,
|
||||
intensity: data.tags.intensity,
|
||||
needsBooking: data.tags.needsBooking,
|
||||
searchQuery: data.tags.searchQuery,
|
||||
searchType: data.tags.searchType,
|
||||
},
|
||||
} : i)));
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : "编辑失败");
|
||||
}
|
||||
}, [profile, toast]);
|
||||
|
||||
const handleDeleteIdea = useCallback(async (ideaId: string) => {
|
||||
if (!profile) return;
|
||||
try {
|
||||
const res = await fetch("/api/blindbox", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ideaId, userId: profile.id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "删除失败");
|
||||
}
|
||||
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
|
||||
setPoolCount((c) => Math.max(0, c - 1));
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
}, [profile, toast]);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
submitting,
|
||||
suggestions,
|
||||
suggestionsLoading,
|
||||
suggestionsSource,
|
||||
poolCount,
|
||||
setPoolCount,
|
||||
myIdeas,
|
||||
drawnHistory,
|
||||
setDrawnHistory,
|
||||
submitFlash,
|
||||
error,
|
||||
setError,
|
||||
inputRef,
|
||||
boxControls,
|
||||
fetchIdeas,
|
||||
fetchSuggestions,
|
||||
refreshSuggestions,
|
||||
handleSubmit,
|
||||
handleEditIdea,
|
||||
handleDeleteIdea,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { getCachedProfile } from "@/lib/userId";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
||||
import type { WeekendPlanData, UserProfile } from "@/types";
|
||||
import type { PendingContract } from "@/components/ContractCompletionModal";
|
||||
|
||||
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
|
||||
|
||||
const PLAN_STATUS_STEPS = [
|
||||
"正在分析你们的想法...",
|
||||
"正在搜索地点...",
|
||||
"正在规划路线...",
|
||||
"快好了...",
|
||||
];
|
||||
|
||||
export function useBlindboxPlan(
|
||||
room: RoomInfo | null,
|
||||
profile: UserProfile | null,
|
||||
phase: Phase,
|
||||
setPhase: (p: Phase) => void,
|
||||
fireConfetti: () => void,
|
||||
) {
|
||||
const toast = useToast();
|
||||
const [planId, setPlanId] = useState<string | null>(null);
|
||||
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
|
||||
const [planAccepted, setPlanAccepted] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [planStatusMessages, setPlanStatusMessages] = useState<string[]>([]);
|
||||
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
||||
const [activeContract, setActiveContract] = useState<{
|
||||
id: string;
|
||||
days: WeekendPlanData[];
|
||||
endTime: string | null;
|
||||
} | null>(null);
|
||||
const [pendingContracts, setPendingContracts] = useState<PendingContract[]>([]);
|
||||
const planLogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (planLogRef.current) {
|
||||
planLogRef.current.scrollTop = planLogRef.current.scrollHeight;
|
||||
}
|
||||
}, [planStatusMessages]);
|
||||
|
||||
const fetchAcceptedPlan = useCallback(async () => {
|
||||
if (!room || !profile) return;
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${profile.id}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.plan) {
|
||||
setActiveContract({
|
||||
id: data.plan.id,
|
||||
days: data.plan.days,
|
||||
endTime: data.plan.endTime ?? null,
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error("fetchAcceptedPlan failed:", e); }
|
||||
}, [room, profile]);
|
||||
|
||||
// Check for expired contracts on load
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${profile.id}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.pending?.length) setPendingContracts(data.pending);
|
||||
} catch (e) { console.error("fetchPendingContracts failed:", e); }
|
||||
})();
|
||||
}, [profile]);
|
||||
|
||||
// Browser notification timer for active contract
|
||||
useEffect(() => {
|
||||
if (!activeContract?.endTime) return;
|
||||
const end = new Date(activeContract.endTime).getTime();
|
||||
const now = Date.now();
|
||||
const ms = end - now;
|
||||
if (ms <= 0) return;
|
||||
|
||||
if (typeof Notification !== "undefined" && Notification.permission === "default") {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
|
||||
const n = new Notification("周末契约到期", {
|
||||
body: "你的周末契约已结束,完成了吗?",
|
||||
icon: "/icon-192x192.png",
|
||||
});
|
||||
n.onclick = () => { window.focus(); n.close(); };
|
||||
}
|
||||
const p = getCachedProfile();
|
||||
if (p) {
|
||||
fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
|
||||
.catch((e) => { console.error("refreshPendingContracts failed:", e); });
|
||||
}
|
||||
}, ms);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeContract?.endTime]);
|
||||
|
||||
const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => {
|
||||
if (generating || !profile || !room) return;
|
||||
setGenerating(true);
|
||||
setPhase("planning");
|
||||
setPlanStatusMessages([PLAN_STATUS_STEPS[0]]);
|
||||
const payload = { roomId: room.id, userId: profile.id, availableTime: timeConfig };
|
||||
const stepRef = { current: 0 };
|
||||
const fallbackTimer = setInterval(() => {
|
||||
stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length;
|
||||
setPlanStatusMessages((prev) => [...prev, PLAN_STATUS_STEPS[stepRef.current]]);
|
||||
}, 2800);
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "生成失败");
|
||||
}
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法读取响应");
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const blocks = buffer.split("\n\n");
|
||||
buffer = blocks.pop() ?? "";
|
||||
for (const block of blocks) {
|
||||
let eventType = "";
|
||||
let data = "";
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:")) eventType = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) data = line.slice(5).trim();
|
||||
}
|
||||
if (eventType === "status") setPlanStatusMessages((prev) => [...prev, data]);
|
||||
else if (eventType === "plan") {
|
||||
const parsed = JSON.parse(data);
|
||||
setPlanId(parsed.id);
|
||||
setPlanDays(parsed.days);
|
||||
setPlanAccepted(false);
|
||||
setPhase("plan_reveal");
|
||||
fireConfetti();
|
||||
} else if (eventType === "error") {
|
||||
toast.show(data || "生成计划失败");
|
||||
setPhase("pool");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "生成失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
setPlanId(data.id);
|
||||
setPlanDays(data.days);
|
||||
setPlanAccepted(false);
|
||||
setPhase("plan_reveal");
|
||||
fireConfetti();
|
||||
} catch (fallbackErr) {
|
||||
toast.show(fallbackErr instanceof Error ? fallbackErr.message : "生成计划失败");
|
||||
setPhase("pool");
|
||||
}
|
||||
} finally {
|
||||
clearInterval(fallbackTimer);
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [generating, profile, room, setPhase, fireConfetti, toast]);
|
||||
|
||||
const handlePlanDaysChange = useCallback(async (newDays: WeekendPlanData[]) => {
|
||||
if (!planId || !profile) return;
|
||||
const prevDays = planDays;
|
||||
setPlanDays(newDays);
|
||||
if (planAccepted) {
|
||||
setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev);
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ planId, userId: profile.id, action: "update_plan", days: newDays }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败");
|
||||
} catch (e) {
|
||||
setPlanDays(prevDays);
|
||||
if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev);
|
||||
toast.show(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
}, [planId, profile, planDays, planAccepted, toast]);
|
||||
|
||||
const handleRefine = useCallback(async (instruction: string) => {
|
||||
if (!profile || !planDays.length) return;
|
||||
const prevDays = planDays;
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan/refine", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, instruction, days: planDays }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败");
|
||||
const data = await res.json();
|
||||
await handlePlanDaysChange(data.days);
|
||||
} catch (e) {
|
||||
setPlanDays(prevDays);
|
||||
toast.show(e instanceof Error ? e.message : "AI 调整失败");
|
||||
}
|
||||
}, [profile, planDays, handlePlanDaysChange, toast]);
|
||||
|
||||
const handleAcceptPlan = useCallback(async () => {
|
||||
setPlanAccepted(true);
|
||||
fireConfetti();
|
||||
if (planId && profile) {
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ planId, userId: profile.id, action: "accept" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setActiveContract({ id: planId, days: planDays, endTime: data.endTime ?? null });
|
||||
} catch (e) { console.error("acceptPlan failed:", e); }
|
||||
}
|
||||
toast.show("契约已接受!");
|
||||
}, [planId, profile, planDays, fireConfetti, toast]);
|
||||
|
||||
const handleRegenerate = useCallback(() => {
|
||||
setPlanId(null);
|
||||
setPlanDays([]);
|
||||
setPlanAccepted(false);
|
||||
setPhase("time_select");
|
||||
}, [setPhase]);
|
||||
|
||||
const showActiveContract = useCallback(() => {
|
||||
if (!activeContract) return;
|
||||
setPlanId(activeContract.id);
|
||||
setPlanDays(activeContract.days);
|
||||
setPlanAccepted(true);
|
||||
setPhase("plan_reveal");
|
||||
}, [activeContract, setPhase]);
|
||||
|
||||
const clearPendingContracts = useCallback(() => {
|
||||
setPendingContracts([]);
|
||||
setActiveContract(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
planId,
|
||||
planDays,
|
||||
setPlanDays,
|
||||
planAccepted,
|
||||
generating,
|
||||
planStatusMessages,
|
||||
showPlanShareCard,
|
||||
setShowPlanShareCard,
|
||||
activeContract,
|
||||
pendingContracts,
|
||||
planLogRef,
|
||||
fetchAcceptedPlan,
|
||||
handleGeneratePlan,
|
||||
handlePlanDaysChange,
|
||||
handleRefine,
|
||||
handleAcceptPlan,
|
||||
handleRegenerate,
|
||||
showActiveContract,
|
||||
clearPendingContracts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useShare } from "@/hooks/useShare";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
export interface RoomInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
creatorId: string;
|
||||
city: string | null;
|
||||
address: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
poolCount: number;
|
||||
members: { id: string; username: string; avatar: string }[];
|
||||
}
|
||||
|
||||
export function useBlindboxRoom(code: string) {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { share, copyToClipboard } = useShare();
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [room, setRoom] = useState<RoomInfo | null>(null);
|
||||
const [isMember, setIsMember] = useState(false);
|
||||
const [joiningRoom, setJoiningRoom] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { timersRef.current.forEach(clearTimeout); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRegistered()) {
|
||||
router.replace("/blindbox");
|
||||
return;
|
||||
}
|
||||
setProfile(getCachedProfile());
|
||||
}, [router]);
|
||||
|
||||
const fetchRoom = useCallback(async () => {
|
||||
if (!code) return;
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/room/${code}`);
|
||||
if (!res.ok) { router.replace("/blindbox"); return; }
|
||||
const data: RoomInfo = await res.json();
|
||||
setRoom(data);
|
||||
const p = getCachedProfile();
|
||||
setIsMember(data.members.some((m) => m.id === p?.id));
|
||||
} catch {
|
||||
router.replace("/blindbox");
|
||||
} finally {
|
||||
setPageLoading(false);
|
||||
}
|
||||
}, [code, router]);
|
||||
|
||||
useEffect(() => { fetchRoom(); }, [fetchRoom]);
|
||||
|
||||
const handleJoinRoom = async () => {
|
||||
if (joiningRoom || !profile || !room) return;
|
||||
setJoiningRoom(true);
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/room/join", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, code }),
|
||||
});
|
||||
if (res.ok) { setIsMember(true); fetchRoom(); }
|
||||
} catch (e) { console.error("handleJoinRoom failed:", e); }
|
||||
finally { setJoiningRoom(false); }
|
||||
};
|
||||
|
||||
const handleSetLocation = useCallback(async () => {
|
||||
if (locating || !profile || !room) return;
|
||||
setLocating(true);
|
||||
try {
|
||||
const pos = await new Promise<GeolocationPosition>((resolve, reject) =>
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }),
|
||||
);
|
||||
const { latitude: lat, longitude: lng } = pos.coords;
|
||||
const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
|
||||
const regeo = regeoRes.ok ? await regeoRes.json() : {};
|
||||
const cityName = regeo.name || "未知位置";
|
||||
const addressLabel = regeo.formatted || cityName;
|
||||
const patchRes = await fetch(`/api/blindbox/room/${room.code}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, city: cityName, address: addressLabel, lat, lng }),
|
||||
});
|
||||
if (!patchRes.ok) throw new Error("保存位置失败");
|
||||
setRoom((prev) => prev ? { ...prev, city: cityName, address: addressLabel, lat, lng } : prev);
|
||||
toast.show("位置已设置");
|
||||
} catch {
|
||||
toast.show("获取位置失败,请允许定位权限");
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}, [locating, profile, room, toast]);
|
||||
|
||||
const handleLeaveOrDelete = async () => {
|
||||
if (!confirmLeave) {
|
||||
setConfirmLeave(true);
|
||||
timersRef.current.push(setTimeout(() => setConfirmLeave(false), 3000));
|
||||
return;
|
||||
}
|
||||
if (leaving || !profile || !room) return;
|
||||
setLeaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/room/${room.code}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "操作失败");
|
||||
}
|
||||
router.push("/blindbox");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : "操作失败");
|
||||
setConfirmLeave(false);
|
||||
} finally {
|
||||
setLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = useCallback(
|
||||
() => room ? copyToClipboard(room.code, "房间号已复制") : undefined,
|
||||
[room, copyToClipboard],
|
||||
);
|
||||
|
||||
const handleShareRoom = useCallback(() => {
|
||||
if (!room) return;
|
||||
const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : "";
|
||||
share(
|
||||
{ title: `周末契约 · ${room.name}`, text: `来和我一起玩周末盲盒吧!房间号:${room.code}`, url },
|
||||
handleCopyCode,
|
||||
);
|
||||
}, [room, share, handleCopyCode]);
|
||||
|
||||
const handleBackToLobby = useCallback(() => {
|
||||
router.push("/blindbox");
|
||||
router.refresh();
|
||||
}, [router]);
|
||||
|
||||
const isCreator = profile?.id === room?.creatorId;
|
||||
|
||||
return {
|
||||
profile,
|
||||
room,
|
||||
isMember,
|
||||
joiningRoom,
|
||||
pageLoading,
|
||||
locating,
|
||||
confirmLeave,
|
||||
leaving,
|
||||
showInvite,
|
||||
setShowInvite,
|
||||
isCreator,
|
||||
handleJoinRoom,
|
||||
handleSetLocation,
|
||||
handleLeaveOrDelete,
|
||||
handleCopyCode,
|
||||
handleShareRoom,
|
||||
handleBackToLobby,
|
||||
fetchRoom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { fetcher, FetchError } from "@/lib/fetcher";
|
||||
|
||||
interface RoomSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
creatorId: string;
|
||||
memberCount: number;
|
||||
poolCount: number;
|
||||
members: { id: string; username: string; avatar: string }[];
|
||||
lastDrawn: { content: string; createdAt: string } | null;
|
||||
}
|
||||
|
||||
interface RoomsResponse {
|
||||
rooms: RoomSummary[];
|
||||
}
|
||||
|
||||
export function useBlindboxRooms(userId: string | undefined) {
|
||||
const { data, error, isLoading, mutate } = useSWR<RoomsResponse>(
|
||||
userId ? `/api/blindbox/rooms?userId=${userId}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
shouldRetryOnError: (err) =>
|
||||
!(err instanceof FetchError && (err.status === 401 || err.status === 403)),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
rooms: data?.rooms ?? [],
|
||||
isLoading,
|
||||
isUnauthorized: error instanceof FetchError && error.status === 401,
|
||||
error,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "@/lib/fetcher";
|
||||
import type { FavoriteRecord } from "@/types";
|
||||
|
||||
export function useFavorites(userId: string | undefined) {
|
||||
const { data, isLoading, mutate } = useSWR<FavoriteRecord[]>(
|
||||
userId ? `/api/user/favorite?userId=${userId}` : null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
return {
|
||||
favorites: data ?? [],
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -2,24 +2,19 @@
|
||||
|
||||
import useSWR from "swr";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { fetcher, FetchError } from "@/lib/fetcher";
|
||||
import { RoomStatus } from "@/types";
|
||||
|
||||
async function fetcher(url: string) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) {
|
||||
const err = new Error(r.status === 404 ? "NOT_FOUND" : "FETCH_ERROR");
|
||||
throw err;
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export function useRoomPolling(roomId: string | undefined) {
|
||||
const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
|
||||
roomId ? `/api/room/${roomId}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: true,
|
||||
shouldRetryOnError: (err) => err?.message !== "NOT_FOUND",
|
||||
shouldRetryOnError: (err) => {
|
||||
if (err instanceof FetchError && (err.status === 401 || err.status === 404)) return false;
|
||||
return err?.message !== "NOT_FOUND";
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user