统一盲盒前端 API 调用层并收敛错误处理

This commit is contained in:
2026-03-03 13:17:42 +08:00
parent 532d8ff7ad
commit 22610f0b59
7 changed files with 204 additions and 128 deletions
+6 -16
View File
@@ -18,6 +18,7 @@ import {
X,
} from "lucide-react";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import { requestJson } from "@/lib/fetcher";
import AuthModal from "@/components/AuthModal";
import Button from "@/components/Button";
import Input from "@/components/Input";
@@ -230,13 +231,10 @@ export default function BlindboxLobbyPage() {
setCreating(true);
setError("");
try {
const res = await fetch("/api/blindbox/room", {
const data = await requestJson<{ code: string }, { name?: string }>("/api/blindbox/room", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name || undefined }),
body: { name: name || undefined },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
router.push(`/blindbox/${data.code}`);
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败");
@@ -251,13 +249,10 @@ export default function BlindboxLobbyPage() {
setJoining(true);
setError("");
try {
const res = await fetch("/api/blindbox/room/join", {
const data = await requestJson<{ code: string }, { code: string }>("/api/blindbox/room/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
body: { code },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
router.push(`/blindbox/${data.code}`);
} catch (e) {
setError(e instanceof Error ? e.message : "加入失败");
@@ -270,14 +265,9 @@ export default function BlindboxLobbyPage() {
if (deletingId || !profile) return;
setDeletingId(room.id);
try {
const res = await fetch(`/api/blindbox/room/${room.code}`, {
await requestJson<{ action: "deleted" | "left" }>(`/api/blindbox/room/${room.code}`, {
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);
setConfirmDeleteId(null);
toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间");
+3 -10
View File
@@ -3,6 +3,7 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useAnimation } from "framer-motion";
import confetti from "canvas-confetti";
import { requestJson } from "@/lib/fetcher";
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
import type { UserProfile } from "@/types";
@@ -78,18 +79,10 @@ export function useBlindboxDraw(
});
try {
const res = await fetch("/api/blindbox/draw", {
const idea = await requestJson<DrawnIdea, { roomId: string }>("/api/blindbox/draw", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: room.id }),
body: { roomId: room.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));
+42 -39
View File
@@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useAnimation } from "framer-motion";
import { useToast } from "@/hooks/useToast";
import { requestJson } from "@/lib/fetcher";
import type { MyIdea } from "@/components/BlindboxMyIdeas";
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
@@ -34,6 +35,30 @@ function pickRandom<T>(arr: T[], n: number): T[] {
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) {
const toast = useToast();
const boxControls = useAnimation();
@@ -62,13 +87,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
const fetchIdeas = useCallback(async () => {
if (!room || !profile) return;
try {
const res = await fetch(`/api/blindbox?roomId=${room.id}`);
if (res.ok) {
const data = await res.json();
setPoolCount(data.poolCount ?? 0);
setMyIdeas(data.myIdeas ?? []);
setDrawnHistory(data.drawn ?? []);
}
const data = await requestJson<IdeasResponse>(`/api/blindbox?roomId=${room.id}`);
setPoolCount(data.poolCount ?? 0);
setMyIdeas(data.myIdeas ?? []);
setDrawnHistory(data.drawn ?? []);
} catch (e) { console.error("fetchIdeas failed:", e); }
}, [room, profile]);
@@ -76,15 +98,13 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
if (!room || !profile) return;
setSuggestionsLoading(true);
try {
const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}`);
if (res.ok) {
const data = await res.json();
if (data.suggestions?.length > 0) {
setSuggestions(data.suggestions);
setSuggestionsSource("ai");
setSuggestionsLoading(false);
return;
}
const data = await requestJson<SuggestionsResponse>(`/api/blindbox/suggest?roomId=${room.id}`);
const suggested = data.suggestions ?? [];
if (suggested.length > 0) {
setSuggestions(suggested);
setSuggestionsSource("ai");
setSuggestionsLoading(false);
return;
}
} catch (e) { console.error("fetchSuggestions failed:", e); }
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
@@ -106,16 +126,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/blindbox", {
const data = await requestJson<IdeaMutationResponse, { roomId: string; content: string }>("/api/blindbox", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: room.id, content: text }),
body: { roomId: room.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) => [{
@@ -153,16 +167,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
const trimmed = newContent.trim();
if (!trimmed || trimmed.length > 200) return;
try {
const res = await fetch("/api/blindbox", {
const data = await requestJson<IdeaMutationResponse, { ideaId: string; content: string }>("/api/blindbox", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ideaId, content: trimmed }),
body: { 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 ? {
...i,
content: trimmed,
@@ -185,15 +193,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n
const handleDeleteIdea = useCallback(async (ideaId: string) => {
if (!profile) return;
try {
const res = await fetch("/api/blindbox", {
await requestJson<{ deleted: boolean }, { ideaId: string }>("/api/blindbox", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ideaId }),
body: { ideaId },
});
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) {
+49 -38
View File
@@ -2,6 +2,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useToast } from "@/hooks/useToast";
import { requestJson } from "@/lib/fetcher";
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
import type { WeekendPlanData, UserProfile } from "@/types";
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(
room: RoomInfo | null,
profile: UserProfile | null,
@@ -46,9 +69,7 @@ export function useBlindboxPlan(
const fetchAcceptedPlan = useCallback(async () => {
if (!room || !profile) return;
try {
const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}`);
if (!res.ok) return;
const data = await res.json();
const data = await requestJson<LatestPlanResponse>(`/api/blindbox/plan?mode=latest&roomId=${room.id}`);
if (data.plan) {
setActiveContract({
id: data.plan.id,
@@ -64,9 +85,7 @@ export function useBlindboxPlan(
if (!profile) return;
(async () => {
try {
const res = await fetch("/api/blindbox/plan?mode=pending");
if (!res.ok) return;
const data = await res.json();
const data = await requestJson<PendingPlansResponse>("/api/blindbox/plan?mode=pending");
if (data.pending?.length) setPendingContracts(data.pending);
} catch (e) { console.error("fetchPendingContracts failed:", e); }
})();
@@ -92,8 +111,7 @@ export function useBlindboxPlan(
});
n.onclick = () => { window.focus(); n.close(); };
}
fetch("/api/blindbox/plan?mode=pending")
.then((r) => r.json())
requestJson<PendingPlansResponse>("/api/blindbox/plan?mode=pending")
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
.catch((e) => { console.error("refreshPendingContracts failed:", e); });
}, ms);
@@ -155,16 +173,10 @@ export function useBlindboxPlan(
}
} catch {
try {
const res = await fetch("/api/blindbox/plan", {
const data = await requestJson<GeneratedPlanResponse, typeof payload>("/api/blindbox/plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
body: 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);
@@ -188,12 +200,13 @@ export function useBlindboxPlan(
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, action: "update_plan", days: newDays }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败");
await requestJson<unknown, { planId: string; action: string; days: WeekendPlanData[] }>(
"/api/blindbox/plan",
{
method: "PATCH",
body: { planId, action: "update_plan", days: newDays },
},
);
} catch (e) {
setPlanDays(prevDays);
if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev);
@@ -205,13 +218,13 @@ export function useBlindboxPlan(
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({ instruction, days: planDays }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败");
const data = await res.json();
const data = await requestJson<{ days: WeekendPlanData[] }, { instruction: string; days: WeekendPlanData[] }>(
"/api/blindbox/plan/refine",
{
method: "POST",
body: { instruction, days: planDays },
},
);
await handlePlanDaysChange(data.days);
} catch (e) {
setPlanDays(prevDays);
@@ -226,15 +239,13 @@ export function useBlindboxPlan(
}
try {
const res = await fetch("/api/blindbox/plan", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId, action: "accept" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || "接受契约失败");
}
const data = await requestJson<AcceptPlanResponse, { planId: string; action: string }>(
"/api/blindbox/plan",
{
method: "PATCH",
body: { planId, action: "accept" },
},
);
setPlanAccepted(true);
setActiveContract({ id: planId, days: planDays, endTime: data.endTime ?? null });
+12 -19
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import { requestJson } from "@/lib/fetcher";
import { useToast } from "@/hooks/useToast";
import { useShare } from "@/hooks/useShare";
import type { UserProfile } from "@/types";
@@ -55,9 +56,7 @@ export function useBlindboxRoom(code: string) {
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();
const data = await requestJson<RoomInfo>(`/api/blindbox/room/${code}`);
setRoom(data);
const p = getCachedProfile();
setIsMember(data.members.some((m) => m.id === p?.id));
@@ -74,12 +73,12 @@ export function useBlindboxRoom(code: string) {
if (joiningRoom || !profile || !room) return;
setJoiningRoom(true);
try {
const res = await fetch("/api/blindbox/room/join", {
await requestJson<{ id: string; code: string; name: string }>("/api/blindbox/room/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
body: { code },
});
if (res.ok) { setIsMember(true); fetchRoom(); }
setIsMember(true);
fetchRoom();
} catch (e) { console.error("handleJoinRoom failed:", e); }
finally { setJoiningRoom(false); }
};
@@ -92,16 +91,15 @@ export function useBlindboxRoom(code: string) {
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 regeo = await requestJson<{ name?: string; formatted?: string }>(
`/api/location/regeo?lat=${lat}&lng=${lng}`,
);
const cityName = regeo.name || "未知位置";
const addressLabel = regeo.formatted || cityName;
const patchRes = await fetch(`/api/blindbox/room/${room.code}`, {
await requestJson(`/api/blindbox/room/${room.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ city: cityName, address: addressLabel, lat, lng }),
body: { 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 {
@@ -120,14 +118,9 @@ export function useBlindboxRoom(code: string) {
if (leaving || !profile || !room) return;
setLeaving(true);
try {
const res = await fetch(`/api/blindbox/room/${room.code}`, {
await requestJson<{ action: "deleted" | "left" }>(`/api/blindbox/room/${room.code}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "操作失败");
}
router.push("/blindbox");
router.refresh();
} catch (e) {
+83
View File
@@ -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> {
const res = await fetch(url);
if (!res.ok) {