统一盲盒前端 API 调用层并收敛错误处理
This commit is contained in:
@@ -229,12 +229,15 @@
|
|||||||
- 证据:
|
- 证据:
|
||||||
- `rg` 检索显示盲盒前端链路已无 `?userId=` 或 `userId: profile.id` 传参。
|
- `rg` 检索显示盲盒前端链路已无 `?userId=` 或 `userId: profile.id` 传参。
|
||||||
|
|
||||||
### R4 统一 API 调用层(减少重复 fetch + 错误处理分散)
|
### R4 统一 API 调用层(减少重复 fetch + 错误处理分散)【已完成】
|
||||||
- 现状:
|
- 修复状态:✅ 已完成(2026-03-03)
|
||||||
- 客户端很多模块各自拼接 URL、手写错误分支。
|
- 修复内容:
|
||||||
- 建议:
|
- 在 `src/lib/fetcher.ts` 增加统一请求入口 `requestJson` 与 `ApiRequestError`,统一 JSON 序列化、响应解析与错误映射;
|
||||||
- 为业务 API 建立 typed client(含统一重试、错误映射、鉴权处理);
|
- 盲盒核心前端链路已迁移到统一调用层(`blindbox/page`、`useBlindboxIdeas`、`useBlindboxRoom`、`useBlindboxDraw`、`useBlindboxPlan`);
|
||||||
- 与 SWR key 规范化一起推进。
|
- 降低重复 `fetch + res.ok + res.json` 模板代码,错误处理集中化。
|
||||||
|
- 证据:
|
||||||
|
- 以上模块中的请求分支已改为 `requestJson(...)` 调用;
|
||||||
|
- 相关盲盒 API/UI 回归测试通过,`npx tsc --noEmit` 通过。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||||
|
import { requestJson } from "@/lib/fetcher";
|
||||||
import AuthModal from "@/components/AuthModal";
|
import AuthModal from "@/components/AuthModal";
|
||||||
import Button from "@/components/Button";
|
import Button from "@/components/Button";
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/Input";
|
||||||
@@ -230,13 +231,10 @@ export default function BlindboxLobbyPage() {
|
|||||||
setCreating(true);
|
setCreating(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/room", {
|
const data = await requestJson<{ code: string }, { name?: string }>("/api/blindbox/room", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { name: name || undefined },
|
||||||
body: JSON.stringify({ name: name || undefined }),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
router.push(`/blindbox/${data.code}`);
|
router.push(`/blindbox/${data.code}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "创建失败");
|
setError(e instanceof Error ? e.message : "创建失败");
|
||||||
@@ -251,13 +249,10 @@ export default function BlindboxLobbyPage() {
|
|||||||
setJoining(true);
|
setJoining(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/room/join", {
|
const data = await requestJson<{ code: string }, { code: string }>("/api/blindbox/room/join", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { code },
|
||||||
body: JSON.stringify({ code }),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error);
|
|
||||||
router.push(`/blindbox/${data.code}`);
|
router.push(`/blindbox/${data.code}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "加入失败");
|
setError(e instanceof Error ? e.message : "加入失败");
|
||||||
@@ -270,14 +265,9 @@ export default function BlindboxLobbyPage() {
|
|||||||
if (deletingId || !profile) return;
|
if (deletingId || !profile) return;
|
||||||
setDeletingId(room.id);
|
setDeletingId(room.id);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/blindbox/room/${room.code}`, {
|
await requestJson<{ action: "deleted" | "left" }>(`/api/blindbox/room/${room.code}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || "操作失败");
|
|
||||||
}
|
|
||||||
mutateRooms((prev) => prev ? { rooms: prev.rooms.filter((r) => r.id !== room.id) } : prev, false);
|
mutateRooms((prev) => prev ? { rooms: prev.rooms.filter((r) => r.id !== room.id) } : prev, false);
|
||||||
setConfirmDeleteId(null);
|
setConfirmDeleteId(null);
|
||||||
toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间");
|
toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { useAnimation } from "framer-motion";
|
import { useAnimation } from "framer-motion";
|
||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
|
import { requestJson } from "@/lib/fetcher";
|
||||||
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
||||||
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
||||||
import type { UserProfile } from "@/types";
|
import type { UserProfile } from "@/types";
|
||||||
@@ -78,18 +79,10 @@ export function useBlindboxDraw(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/draw", {
|
const idea = await requestJson<DrawnIdea, { roomId: string }>("/api/blindbox/draw", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { roomId: room.id },
|
||||||
body: JSON.stringify({ roomId: room.id }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || "抽取失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
const idea = await res.json();
|
|
||||||
setRevealedIdea(idea);
|
setRevealedIdea(idea);
|
||||||
setPhase("reveal");
|
setPhase("reveal");
|
||||||
setPoolCount((c) => Math.max(0, c - 1));
|
setPoolCount((c) => Math.max(0, c - 1));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useAnimation } from "framer-motion";
|
import { useAnimation } from "framer-motion";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { requestJson } from "@/lib/fetcher";
|
||||||
import type { MyIdea } from "@/components/BlindboxMyIdeas";
|
import type { MyIdea } from "@/components/BlindboxMyIdeas";
|
||||||
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
||||||
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
||||||
@@ -34,6 +35,30 @@ function pickRandom<T>(arr: T[], n: number): T[] {
|
|||||||
return shuffled.slice(0, n);
|
return shuffled.slice(0, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IdeasResponse {
|
||||||
|
poolCount?: number;
|
||||||
|
myIdeas?: MyIdea[];
|
||||||
|
drawn?: DrawnIdea[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuggestionsResponse {
|
||||||
|
suggestions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IdeaMutationResponse {
|
||||||
|
id: string;
|
||||||
|
tags?: {
|
||||||
|
category: string;
|
||||||
|
timeSlot: string;
|
||||||
|
estimatedMinutes: number;
|
||||||
|
costLevel: string;
|
||||||
|
intensity: string;
|
||||||
|
needsBooking: boolean;
|
||||||
|
searchQuery: string;
|
||||||
|
searchType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | null) {
|
export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | null) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const boxControls = useAnimation();
|
const boxControls = useAnimation();
|
||||||
@@ -62,13 +87,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
|
|||||||
const fetchIdeas = useCallback(async () => {
|
const fetchIdeas = useCallback(async () => {
|
||||||
if (!room || !profile) return;
|
if (!room || !profile) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/blindbox?roomId=${room.id}`);
|
const data = await requestJson<IdeasResponse>(`/api/blindbox?roomId=${room.id}`);
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setPoolCount(data.poolCount ?? 0);
|
setPoolCount(data.poolCount ?? 0);
|
||||||
setMyIdeas(data.myIdeas ?? []);
|
setMyIdeas(data.myIdeas ?? []);
|
||||||
setDrawnHistory(data.drawn ?? []);
|
setDrawnHistory(data.drawn ?? []);
|
||||||
}
|
|
||||||
} catch (e) { console.error("fetchIdeas failed:", e); }
|
} catch (e) { console.error("fetchIdeas failed:", e); }
|
||||||
}, [room, profile]);
|
}, [room, profile]);
|
||||||
|
|
||||||
@@ -76,16 +98,14 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
|
|||||||
if (!room || !profile) return;
|
if (!room || !profile) return;
|
||||||
setSuggestionsLoading(true);
|
setSuggestionsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}`);
|
const data = await requestJson<SuggestionsResponse>(`/api/blindbox/suggest?roomId=${room.id}`);
|
||||||
if (res.ok) {
|
const suggested = data.suggestions ?? [];
|
||||||
const data = await res.json();
|
if (suggested.length > 0) {
|
||||||
if (data.suggestions?.length > 0) {
|
setSuggestions(suggested);
|
||||||
setSuggestions(data.suggestions);
|
|
||||||
setSuggestionsSource("ai");
|
setSuggestionsSource("ai");
|
||||||
setSuggestionsLoading(false);
|
setSuggestionsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) { console.error("fetchSuggestions failed:", e); }
|
} catch (e) { console.error("fetchSuggestions failed:", e); }
|
||||||
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
|
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
|
||||||
setSuggestionsSource("static");
|
setSuggestionsSource("static");
|
||||||
@@ -106,16 +126,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox", {
|
const data = await requestJson<IdeaMutationResponse, { roomId: string; content: string }>("/api/blindbox", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { roomId: room.id, content: text },
|
||||||
body: JSON.stringify({ roomId: room.id, content: text }),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || "提交失败");
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
setInput("");
|
setInput("");
|
||||||
setPoolCount((c) => c + 1);
|
setPoolCount((c) => c + 1);
|
||||||
setMyIdeas((prev) => [{
|
setMyIdeas((prev) => [{
|
||||||
@@ -153,16 +167,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
|
|||||||
const trimmed = newContent.trim();
|
const trimmed = newContent.trim();
|
||||||
if (!trimmed || trimmed.length > 200) return;
|
if (!trimmed || trimmed.length > 200) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox", {
|
const data = await requestJson<IdeaMutationResponse, { ideaId: string; content: string }>("/api/blindbox", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { ideaId, content: trimmed },
|
||||||
body: JSON.stringify({ ideaId, 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 ? {
|
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? {
|
||||||
...i,
|
...i,
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
@@ -185,15 +193,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
|
|||||||
const handleDeleteIdea = useCallback(async (ideaId: string) => {
|
const handleDeleteIdea = useCallback(async (ideaId: string) => {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox", {
|
await requestJson<{ deleted: boolean }, { ideaId: string }>("/api/blindbox", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { ideaId },
|
||||||
body: JSON.stringify({ ideaId }),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || "删除失败");
|
|
||||||
}
|
|
||||||
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
|
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
|
||||||
setPoolCount((c) => Math.max(0, c - 1));
|
setPoolCount((c) => Math.max(0, c - 1));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { requestJson } from "@/lib/fetcher";
|
||||||
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
||||||
import type { WeekendPlanData, UserProfile } from "@/types";
|
import type { WeekendPlanData, UserProfile } from "@/types";
|
||||||
import type { PendingContract } from "@/components/ContractCompletionModal";
|
import type { PendingContract } from "@/components/ContractCompletionModal";
|
||||||
@@ -15,6 +16,28 @@ const PLAN_STATUS_STEPS = [
|
|||||||
"快好了...",
|
"快好了...",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface LatestPlanResponse {
|
||||||
|
plan?: {
|
||||||
|
id: string;
|
||||||
|
days: WeekendPlanData[];
|
||||||
|
endTime?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingPlansResponse {
|
||||||
|
pending?: PendingContract[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratedPlanResponse {
|
||||||
|
id: string;
|
||||||
|
days: WeekendPlanData[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcceptPlanResponse {
|
||||||
|
endTime?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function useBlindboxPlan(
|
export function useBlindboxPlan(
|
||||||
room: RoomInfo | null,
|
room: RoomInfo | null,
|
||||||
profile: UserProfile | null,
|
profile: UserProfile | null,
|
||||||
@@ -46,9 +69,7 @@ export function useBlindboxPlan(
|
|||||||
const fetchAcceptedPlan = useCallback(async () => {
|
const fetchAcceptedPlan = useCallback(async () => {
|
||||||
if (!room || !profile) return;
|
if (!room || !profile) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}`);
|
const data = await requestJson<LatestPlanResponse>(`/api/blindbox/plan?mode=latest&roomId=${room.id}`);
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.plan) {
|
if (data.plan) {
|
||||||
setActiveContract({
|
setActiveContract({
|
||||||
id: data.plan.id,
|
id: data.plan.id,
|
||||||
@@ -64,9 +85,7 @@ export function useBlindboxPlan(
|
|||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/plan?mode=pending");
|
const data = await requestJson<PendingPlansResponse>("/api/blindbox/plan?mode=pending");
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.pending?.length) setPendingContracts(data.pending);
|
if (data.pending?.length) setPendingContracts(data.pending);
|
||||||
} catch (e) { console.error("fetchPendingContracts failed:", e); }
|
} catch (e) { console.error("fetchPendingContracts failed:", e); }
|
||||||
})();
|
})();
|
||||||
@@ -92,8 +111,7 @@ export function useBlindboxPlan(
|
|||||||
});
|
});
|
||||||
n.onclick = () => { window.focus(); n.close(); };
|
n.onclick = () => { window.focus(); n.close(); };
|
||||||
}
|
}
|
||||||
fetch("/api/blindbox/plan?mode=pending")
|
requestJson<PendingPlansResponse>("/api/blindbox/plan?mode=pending")
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
|
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
|
||||||
.catch((e) => { console.error("refreshPendingContracts failed:", e); });
|
.catch((e) => { console.error("refreshPendingContracts failed:", e); });
|
||||||
}, ms);
|
}, ms);
|
||||||
@@ -155,16 +173,10 @@ export function useBlindboxPlan(
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/plan", {
|
const data = await requestJson<GeneratedPlanResponse, typeof payload>("/api/blindbox/plan", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: payload,
|
||||||
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);
|
setPlanId(data.id);
|
||||||
setPlanDays(data.days);
|
setPlanDays(data.days);
|
||||||
setPlanAccepted(false);
|
setPlanAccepted(false);
|
||||||
@@ -188,12 +200,13 @@ export function useBlindboxPlan(
|
|||||||
setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev);
|
setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/plan", {
|
await requestJson<unknown, { planId: string; action: string; days: WeekendPlanData[] }>(
|
||||||
|
"/api/blindbox/plan",
|
||||||
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { planId, action: "update_plan", days: newDays },
|
||||||
body: JSON.stringify({ planId, action: "update_plan", days: newDays }),
|
},
|
||||||
});
|
);
|
||||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPlanDays(prevDays);
|
setPlanDays(prevDays);
|
||||||
if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev);
|
if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev);
|
||||||
@@ -205,13 +218,13 @@ export function useBlindboxPlan(
|
|||||||
if (!profile || !planDays.length) return;
|
if (!profile || !planDays.length) return;
|
||||||
const prevDays = planDays;
|
const prevDays = planDays;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/plan/refine", {
|
const data = await requestJson<{ days: WeekendPlanData[] }, { instruction: string; days: WeekendPlanData[] }>(
|
||||||
|
"/api/blindbox/plan/refine",
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { instruction, days: planDays },
|
||||||
body: JSON.stringify({ instruction, days: planDays }),
|
},
|
||||||
});
|
);
|
||||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败");
|
|
||||||
const data = await res.json();
|
|
||||||
await handlePlanDaysChange(data.days);
|
await handlePlanDaysChange(data.days);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPlanDays(prevDays);
|
setPlanDays(prevDays);
|
||||||
@@ -226,15 +239,13 @@ export function useBlindboxPlan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/plan", {
|
const data = await requestJson<AcceptPlanResponse, { planId: string; action: string }>(
|
||||||
|
"/api/blindbox/plan",
|
||||||
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { planId, action: "accept" },
|
||||||
body: JSON.stringify({ planId, action: "accept" }),
|
},
|
||||||
});
|
);
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error || "接受契约失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlanAccepted(true);
|
setPlanAccepted(true);
|
||||||
setActiveContract({ id: planId, days: planDays, endTime: data.endTime ?? null });
|
setActiveContract({ id: planId, days: planDays, endTime: data.endTime ?? null });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||||
|
import { requestJson } from "@/lib/fetcher";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { useShare } from "@/hooks/useShare";
|
import { useShare } from "@/hooks/useShare";
|
||||||
import type { UserProfile } from "@/types";
|
import type { UserProfile } from "@/types";
|
||||||
@@ -55,9 +56,7 @@ export function useBlindboxRoom(code: string) {
|
|||||||
const fetchRoom = useCallback(async () => {
|
const fetchRoom = useCallback(async () => {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/blindbox/room/${code}`);
|
const data = await requestJson<RoomInfo>(`/api/blindbox/room/${code}`);
|
||||||
if (!res.ok) { router.replace("/blindbox"); return; }
|
|
||||||
const data: RoomInfo = await res.json();
|
|
||||||
setRoom(data);
|
setRoom(data);
|
||||||
const p = getCachedProfile();
|
const p = getCachedProfile();
|
||||||
setIsMember(data.members.some((m) => m.id === p?.id));
|
setIsMember(data.members.some((m) => m.id === p?.id));
|
||||||
@@ -74,12 +73,12 @@ export function useBlindboxRoom(code: string) {
|
|||||||
if (joiningRoom || !profile || !room) return;
|
if (joiningRoom || !profile || !room) return;
|
||||||
setJoiningRoom(true);
|
setJoiningRoom(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/blindbox/room/join", {
|
await requestJson<{ id: string; code: string; name: string }>("/api/blindbox/room/join", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { code },
|
||||||
body: JSON.stringify({ code }),
|
|
||||||
});
|
});
|
||||||
if (res.ok) { setIsMember(true); fetchRoom(); }
|
setIsMember(true);
|
||||||
|
fetchRoom();
|
||||||
} catch (e) { console.error("handleJoinRoom failed:", e); }
|
} catch (e) { console.error("handleJoinRoom failed:", e); }
|
||||||
finally { setJoiningRoom(false); }
|
finally { setJoiningRoom(false); }
|
||||||
};
|
};
|
||||||
@@ -92,16 +91,15 @@ export function useBlindboxRoom(code: string) {
|
|||||||
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }),
|
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }),
|
||||||
);
|
);
|
||||||
const { latitude: lat, longitude: lng } = pos.coords;
|
const { latitude: lat, longitude: lng } = pos.coords;
|
||||||
const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
|
const regeo = await requestJson<{ name?: string; formatted?: string }>(
|
||||||
const regeo = regeoRes.ok ? await regeoRes.json() : {};
|
`/api/location/regeo?lat=${lat}&lng=${lng}`,
|
||||||
|
);
|
||||||
const cityName = regeo.name || "未知位置";
|
const cityName = regeo.name || "未知位置";
|
||||||
const addressLabel = regeo.formatted || cityName;
|
const addressLabel = regeo.formatted || cityName;
|
||||||
const patchRes = await fetch(`/api/blindbox/room/${room.code}`, {
|
await requestJson(`/api/blindbox/room/${room.code}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: { city: cityName, address: addressLabel, lat, lng },
|
||||||
body: JSON.stringify({ city: cityName, address: addressLabel, lat, lng }),
|
|
||||||
});
|
});
|
||||||
if (!patchRes.ok) throw new Error("保存位置失败");
|
|
||||||
setRoom((prev) => prev ? { ...prev, city: cityName, address: addressLabel, lat, lng } : prev);
|
setRoom((prev) => prev ? { ...prev, city: cityName, address: addressLabel, lat, lng } : prev);
|
||||||
toast.show("位置已设置");
|
toast.show("位置已设置");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -120,14 +118,9 @@ export function useBlindboxRoom(code: string) {
|
|||||||
if (leaving || !profile || !room) return;
|
if (leaving || !profile || !room) return;
|
||||||
setLeaving(true);
|
setLeaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/blindbox/room/${room.code}`, {
|
await requestJson<{ action: "deleted" | "left" }>(`/api/blindbox/room/${room.code}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || "操作失败");
|
|
||||||
}
|
|
||||||
router.push("/blindbox");
|
router.push("/blindbox");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -12,6 +12,89 @@ export class FetchError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number,
|
||||||
|
public payload?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiRequestError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(payload: unknown): string | null {
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"error" in payload &&
|
||||||
|
typeof payload.error === "string"
|
||||||
|
) {
|
||||||
|
return payload.error;
|
||||||
|
}
|
||||||
|
if (typeof payload === "string" && payload.trim()) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parsePayload(res: Response): Promise<unknown> {
|
||||||
|
if (res.status === 204) return undefined;
|
||||||
|
if (typeof res.text === "function") {
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof res.json === "function") {
|
||||||
|
try {
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestJsonInit<TBody> = Omit<RequestInit, "body"> & {
|
||||||
|
body?: TBody;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared JSON requester for imperative client-side API calls.
|
||||||
|
* - Parses JSON/text payloads automatically
|
||||||
|
* - Throws ApiRequestError with normalized message on non-2xx
|
||||||
|
*/
|
||||||
|
export async function requestJson<TResponse = unknown, TBody = unknown>(
|
||||||
|
url: string,
|
||||||
|
init: RequestJsonInit<TBody> = {},
|
||||||
|
): Promise<TResponse> {
|
||||||
|
const { body, headers, ...rest } = init;
|
||||||
|
const hasBody = body !== undefined;
|
||||||
|
|
||||||
|
const mergedHeaders = new Headers(headers);
|
||||||
|
if (hasBody && !mergedHeaders.has("Content-Type")) {
|
||||||
|
mergedHeaders.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...rest,
|
||||||
|
headers: mergedHeaders,
|
||||||
|
body: hasBody ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await parsePayload(res);
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = extractErrorMessage(payload) ?? "请求失败";
|
||||||
|
throw new ApiRequestError(message, res.status, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as TResponse;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetcher<T = unknown>(url: string): Promise<T> {
|
export async function fetcher<T = unknown>(url: string): Promise<T> {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user