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:
2026-03-02 18:05:06 +08:00
parent ce76980fe5
commit 6bb0e65d4c
34 changed files with 2759 additions and 2669 deletions
+6 -30
View File
@@ -19,17 +19,11 @@ import ContractHistoryItem from "@/components/ContractHistoryItem";
import EmptyState from "@/components/EmptyState";
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { buildNavUrl } from "@/lib/navigation";
import type { DecisionRecord, ContractRecord, Restaurant } from "@/types";
import { useAchievements } from "@/hooks/useAchievements";
import type { Restaurant } from "@/types";
type Tab = "decisions" | "contracts";
interface Stats {
totalDecisions: number;
totalContracts: number;
completedContracts: number;
completionRate: number;
}
function firstImage(r: Restaurant): string {
if (r.images?.length > 0) return r.images[0];
const legacy = (r as unknown as Record<string, unknown>).image;
@@ -38,16 +32,8 @@ function firstImage(r: Restaurant): string {
export default function AchievementsPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("decisions");
const [stats, setStats] = useState<Stats>({
totalDecisions: 0,
totalContracts: 0,
completedContracts: 0,
completionRate: 0,
});
const [decisions, setDecisions] = useState<DecisionRecord[]>([]);
const [contracts, setContracts] = useState<ContractRecord[]>([]);
const [userId, setUserId] = useState<string | undefined>(undefined);
useEffect(() => {
if (!isRegistered()) {
@@ -55,21 +41,11 @@ export default function AchievementsPage() {
return;
}
const p = getCachedProfile();
if (!p) return;
(async () => {
try {
const res = await fetch(`/api/user/achievements?userId=${p.id}`);
if (!res.ok) return;
const data = await res.json();
setStats(data.stats);
setDecisions(data.decisions);
setContracts(data.contracts);
} catch (e) { console.error("AchievementsPage: fetch failed:", e); }
finally { setLoading(false); }
})();
if (p) setUserId(p.id);
}, [router]);
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
const statCards = [
{
label: "决策记录",
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { suggestAlternativeItems } from "@/lib/ai";
import { searchPois } from "@/lib/blindboxPlanGen";
import { searchPois } from "@/lib/amap";
export const POST = apiHandler(async (req) => {
const { activity, time, location } = await req.json();
+13 -65
View File
@@ -4,7 +4,7 @@
* Only available in development.
*/
import { NextRequest, NextResponse } from "next/server";
import { requireAmapApiKey } from "@/lib/amap";
import { getTransitDirection } from "@/lib/amap";
export async function GET(req: NextRequest) {
if (process.env.NODE_ENV !== "development") {
@@ -22,69 +22,17 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "需要 olat/olng/dlat/dlng 参数" }, { status: 400 });
}
const apiKey = requireAmapApiKey();
const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated");
url.searchParams.set("key", apiKey);
url.searchParams.set("origin", `${oLng},${oLat}`);
url.searchParams.set("destination", `${dLng},${dLat}`);
url.searchParams.set("city", city);
url.searchParams.set("cityd", city);
url.searchParams.set("output", "json");
const res = await fetch(url.toString());
const raw = await res.json();
if (raw.status !== "1" || !raw.route?.transits?.length) {
return NextResponse.json({ error: "未找到路线", raw });
}
const transit = raw.route.transits[0];
const durationMin = Math.ceil(Number(transit.duration) / 60);
const distanceKm = Math.round(Number(raw.route.distance) / 100) / 10;
// Parse segments the same way as production code
const parts: string[] = [];
const segmentDebug: unknown[] = [];
for (const seg of (transit.segments ?? []) as Record<string, unknown>[]) {
const bus = seg.bus as { buslines?: Record<string, unknown>[] } | undefined;
segmentDebug.push({
hasWalking: !!seg.walking,
hasBus: !!seg.bus,
buslines: bus?.buslines?.map((l) => ({
name: l.name,
type: l.type,
via_num: l.via_num,
departure: (l.departure_stop as Record<string, unknown> | undefined)?.name,
arrival: (l.arrival_stop as Record<string, unknown> | undefined)?.name,
})),
});
if (!bus?.buslines?.length) continue;
for (const line of bus.buslines) {
const name = String(line.name ?? "");
const viaNum = Number(line.via_num ?? 0);
parts.push(viaNum > 0 ? `${name}(${viaNum}站)` : name);
}
}
return NextResponse.json({
parsed: {
durationMin,
distanceKm,
description: parts.join(" → ") || "步行",
},
segmentDebug,
rawTransit: {
duration: transit.duration,
nightflag: transit.nightflag,
segmentCount: transit.segments?.length,
},
// First 3 routes for comparison
allRoutes: raw.route.transits.slice(0, 3).map((t: Record<string, unknown>) => ({
durationMin: Math.ceil(Number(t.duration) / 60),
segments: (t.segments as Record<string, unknown>[] ?? []).map((s) => {
const b = s.bus as { buslines?: Record<string, unknown>[] } | undefined;
return b?.buslines?.map((l) => `${l.name}(${l.via_num}站)`) ?? ["步行"];
}),
})),
const result = await getTransitDirection({
originLat: oLat,
originLng: oLng,
destLat: dLat,
destLng: dLng,
city,
});
if (!result) {
return NextResponse.json({ error: "未找到路线" });
}
return NextResponse.json({ parsed: result });
}
+8 -21
View File
@@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockReverseGeocode = vi.fn();
vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
reverseGeocode: (...args: unknown[]) => mockReverseGeocode(...args),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
@@ -20,19 +18,9 @@ beforeEach(() => {
describe("GET /api/location/regeo", () => {
it("returns reverse geocoded location", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
regeocode: {
formatted_address: "上海市黄浦区人民大道",
addressComponent: {
district: "黄浦区",
township: "南京东路街道",
neighborhood: { name: "人民广场" },
},
},
}),
mockReverseGeocode.mockResolvedValue({
name: "黄浦区 南京东路街道 人民广场",
formatted: "上海市黄浦区人民大道",
});
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
@@ -45,9 +33,7 @@ describe("GET /api/location/regeo", () => {
});
it("returns null name when API returns no result", async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ status: "0" }),
});
mockReverseGeocode.mockResolvedValue({ name: null, formatted: null });
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx);
@@ -62,7 +48,8 @@ describe("GET /api/location/regeo", () => {
});
it("returns 503 when API unavailable", async () => {
mockFetch.mockRejectedValue(new Error("network"));
const { ApiError } = await import("@/lib/api");
mockReverseGeocode.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx);
+3 -34
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { requireAmapApiKey } from "@/lib/amap";
import { reverseGeocode } from "@/lib/amap";
export const GET = apiHandler(async (req) => {
const lat = req.nextUrl.searchParams.get("lat");
@@ -8,37 +8,6 @@ export const GET = apiHandler(async (req) => {
if (!lat || !lng) throw new ApiError("lat and lng are required");
const apiKey = requireAmapApiKey();
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
url.searchParams.set("key", apiKey);
url.searchParams.set("location", `${lng},${lat}`);
url.searchParams.set("extensions", "base");
let data;
try {
const res = await fetch(url.toString());
data = await res.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
if (data.status !== "1" || !data.regeocode) {
return NextResponse.json({ name: null });
}
const comp = data.regeocode.addressComponent;
const district = comp?.district || comp?.city || "";
const township = comp?.township || "";
const neighborhood = comp?.neighborhood?.name || "";
const name = [district, township, neighborhood]
.filter(Boolean)
.join(" ")
.trim();
return NextResponse.json({
name: name || data.regeocode.formatted_address || null,
formatted: data.regeocode.formatted_address || null,
});
const result = await reverseGeocode({ lat: Number(lat), lng: Number(lng) });
return NextResponse.json(result);
});
+16 -23
View File
@@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockSearchPlaceText = vi.fn();
vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
searchPlaceText: (...args: unknown[]) => mockSearchPlaceText(...args),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
@@ -20,21 +18,17 @@ beforeEach(() => {
describe("GET /api/location/search", () => {
it("returns search results", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
pois: [
{
id: "poi-1",
name: "星巴克",
address: "南京路1号",
location: "121.4,31.2",
business: { rating: "4.5", cost: "40" },
},
],
}),
});
mockSearchPlaceText.mockResolvedValue([
{
id: "poi-1",
name: "星巴克",
address: "南京路1号",
lat: 31.2,
lng: 121.4,
rating: 4.5,
cost: 40,
},
]);
const req = createRequest("/api/location/search?keywords=星巴克");
const res = await GET(req, mockCtx);
@@ -48,9 +42,7 @@ describe("GET /api/location/search", () => {
});
it("returns empty when no results", async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ status: "1", pois: [] }),
});
mockSearchPlaceText.mockResolvedValue([]);
const req = createRequest("/api/location/search?keywords=不存在的地方");
const res = await GET(req, mockCtx);
@@ -65,7 +57,8 @@ describe("GET /api/location/search", () => {
});
it("returns 503 when API unavailable", async () => {
mockFetch.mockRejectedValue(new Error("network error"));
const { ApiError } = await import("@/lib/api");
mockSearchPlaceText.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
const req = createRequest("/api/location/search?keywords=test");
const res = await GET(req, mockCtx);
+2 -57
View File
@@ -1,19 +1,6 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { requireAmapApiKey } from "@/lib/amap";
interface AmapPoiV5 {
id: string;
name: string;
address?: string;
location?: string;
type?: string;
business?: {
rating?: string;
cost?: string;
tel?: string;
};
}
import { searchPlaceText } from "@/lib/amap";
export const GET = apiHandler(async (req) => {
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
@@ -22,48 +9,6 @@ export const GET = apiHandler(async (req) => {
const city = req.nextUrl.searchParams.get("city")?.trim();
const types = req.nextUrl.searchParams.get("types")?.trim();
const apiKey = requireAmapApiKey();
const url = new URL("https://restapi.amap.com/v5/place/text");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", keywords);
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", "10");
if (city) url.searchParams.set("region", city);
if (types) url.searchParams.set("types", types);
let data;
try {
const res = await fetch(url.toString());
data = await res.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
if (data.status !== "1" || !data.pois?.length) {
return NextResponse.json([]);
}
const results = data.pois
.filter((poi: AmapPoiV5) => poi.location)
.map((poi: AmapPoiV5) => {
const [lng, lat] = (poi.location ?? "0,0").split(",").map(Number);
const ratingStr = poi.business?.rating;
const rating = ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null;
const costStr = poi.business?.cost;
const cost = costStr && costStr !== "[]" && costStr !== "0" ? Number(costStr) : null;
return {
id: poi.id,
name: poi.name,
address: poi.address || "",
lat,
lng,
rating,
cost,
};
});
const results = await searchPlaceText({ keywords, city: city || undefined, types: types || undefined });
return NextResponse.json(results);
});
+17 -30
View File
@@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockGetInputTips = vi.fn();
vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
getInputTips: (...args: unknown[]) => mockGetInputTips(...args),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
@@ -20,21 +18,16 @@ beforeEach(() => {
describe("GET /api/location/suggest", () => {
it("returns suggestions", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
tips: [
{
id: "tip-1",
name: "人民广场",
district: "黄浦区",
address: "人民大道",
location: "121.4737,31.2304",
},
],
}),
});
mockGetInputTips.mockResolvedValue([
{
id: "tip-1",
name: "人民广场",
district: "黄浦区",
address: "人民大道",
lat: 31.2304,
lng: 121.4737,
},
]);
const req = createRequest("/api/location/suggest?keywords=人民广场");
const res = await GET(req, mockCtx);
@@ -53,16 +46,9 @@ describe("GET /api/location/suggest", () => {
});
it("filters tips without location", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
tips: [
{ id: "tip-1", name: "有位置", location: "121.4,31.2" },
{ id: "tip-2", name: "无位置", location: "" },
],
}),
});
mockGetInputTips.mockResolvedValue([
{ id: "tip-1", name: "有位置", district: "", address: "", lat: 31.2, lng: 121.4 },
]);
const req = createRequest("/api/location/suggest?keywords=test");
const res = await GET(req, mockCtx);
@@ -72,7 +58,8 @@ describe("GET /api/location/suggest", () => {
});
it("returns 503 when API fails", async () => {
mockFetch.mockRejectedValue(new Error("network"));
const { ApiError } = await import("@/lib/api");
mockGetInputTips.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
const req = createRequest("/api/location/suggest?keywords=test");
const res = await GET(req, mockCtx);
+4 -39
View File
@@ -1,47 +1,12 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { requireAmapApiKey } from "@/lib/amap";
import { apiHandler } from "@/lib/api";
import { getInputTips } from "@/lib/amap";
export const GET = apiHandler(async (req) => {
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
if (!keywords) return NextResponse.json([]);
const apiKey = requireAmapApiKey();
const location = req.nextUrl.searchParams.get("location");
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", keywords);
url.searchParams.set("datatype", "poi");
if (location) {
url.searchParams.set("location", location);
}
let data;
try {
const res = await fetch(url.toString());
data = await res.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
if (data.status !== "1" || !data.tips) return NextResponse.json([]);
const suggestions = data.tips
.filter((t: { location?: string }) => t.location && t.location !== "")
.slice(0, 8)
.map((t: { id: string; name: string; district?: string; address?: string; location: string }) => {
const [lng, lat] = t.location.split(",").map(Number);
return {
id: t.id,
name: t.name,
district: t.district || "",
address: t.address || "",
lat,
lng,
};
});
const location = req.nextUrl.searchParams.get("location") || undefined;
const suggestions = await getInputTips({ keywords, location });
return NextResponse.json(suggestions);
});
File diff suppressed because it is too large Load Diff
+23 -51
View File
@@ -23,6 +23,7 @@ import Button from "@/components/Button";
import Input from "@/components/Input";
import { BlindboxListSkeleton } from "@/components/Skeleton";
import { useToast } from "@/hooks/useToast";
import { useBlindboxRooms } from "@/hooks/useBlindboxRooms";
import type { UserProfile } from "@/types";
interface RoomSummary {
@@ -169,77 +170,48 @@ export default function BlindboxLobbyPage() {
const [loggedIn, setLoggedIn] = useState(false);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [showAuth, setShowAuth] = useState(false);
const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [loading, setLoading] = useState(true);
const toast = useToast();
const { rooms: swrRooms, isLoading: swrLoading, isUnauthorized, error: swrError, mutate: mutateRooms } = useBlindboxRooms(
loggedIn && profile ? profile.id : undefined,
);
const rooms = swrRooms;
const loading = !loggedIn ? false : swrLoading;
// JWT 过期时,重置登录状态让用户重新登录
useEffect(() => {
if (isUnauthorized && loggedIn) {
setLoggedIn(false);
setProfile(null);
setShowAuth(true);
}
}, [isUnauthorized, loggedIn]);
const createNameRef = useRef<HTMLInputElement>(null);
const joinCodeRef = useRef<HTMLInputElement>(null);
const [joinCodeLength, setJoinCodeLength] = useState(0);
const [creating, setCreating] = useState(false);
const [joining, setJoining] = useState(false);
const [error, setError] = useState("");
const [loadError, setLoadError] = useState<string | false>(false);
const loadError = swrError ? (swrError instanceof Error ? swrError.message : "未知错误") : false;
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchRooms = useCallback(async (silent = false) => {
const p = getCachedProfile();
if (!p) return;
if (!silent) {
setLoading(true);
setLoadError(false);
}
try {
const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`, { cache: "no-store" });
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
const data = await res.json();
setRooms(Array.isArray(data.rooms) ? data.rooms : []);
setLoadError(false);
} catch (e) {
if (!silent) setLoadError(e instanceof Error ? e.message : "未知错误");
} finally {
if (!silent) setLoading(false);
}
}, []);
useEffect(() => {
const registered = isRegistered();
setLoggedIn(registered);
if (registered) {
setProfile(getCachedProfile());
fetchRooms();
} else {
setLoading(false);
}
if (registered) setProfile(getCachedProfile());
setHydrated(true);
}, [fetchRooms]);
}, []);
useEffect(() => {
const handler = () => {
const registered = isRegistered();
setLoggedIn(registered);
setProfile(registered ? getCachedProfile() : null);
if (registered) fetchRooms();
};
window.addEventListener("nowhatever_auth", handler);
return () => window.removeEventListener("nowhatever_auth", handler);
}, [fetchRooms]);
useEffect(() => {
if (!loggedIn) return;
const refresh = () => fetchRooms(true);
window.addEventListener("focus", refresh);
window.addEventListener("pageshow", refresh);
return () => {
window.removeEventListener("focus", refresh);
window.removeEventListener("pageshow", refresh);
};
}, [loggedIn, fetchRooms]);
}, []);
useEffect(() => {
if (rooms.length > 0) setJoinCodeLength(0);
@@ -249,8 +221,8 @@ export default function BlindboxLobbyPage() {
setProfile(p);
setLoggedIn(true);
setShowAuth(false);
fetchRooms();
}, [fetchRooms]);
mutateRooms();
}, [mutateRooms]);
const handleCreate = async () => {
if (creating || !profile) return;
@@ -307,7 +279,7 @@ export default function BlindboxLobbyPage() {
const data = await res.json();
throw new Error(data.error || "操作失败");
}
setRooms((prev) => prev.filter((r) => r.id !== room.id));
mutateRooms((prev) => prev ? { rooms: prev.rooms.filter((r) => r.id !== room.id) } : prev, false);
setConfirmDeleteId(null);
toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间");
} catch (e) {
@@ -441,7 +413,7 @@ export default function BlindboxLobbyPage() {
<p className="max-w-xs text-center text-[11px] text-muted/60 break-all">{loadError}</p>
)}
<button
onClick={() => fetchRooms()}
onClick={() => mutateRooms()}
className="mt-1 text-xs font-medium text-purple-400 active:text-purple-300"
>
+5 -16
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
@@ -22,10 +22,11 @@ import Card from "@/components/Card";
import Input from "@/components/Input";
import ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
import { useToast } from "@/hooks/useToast";
import { useFavorites } from "@/hooks/useFavorites";
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
import { getAvatarBg, AVATARS } from "@/lib/avatars";
import type { UserProfile, UserPreferences, FavoriteRecord } from "@/types";
import type { UserProfile, UserPreferences } from "@/types";
export default function ProfilePage() {
const router = useRouter();
@@ -33,8 +34,7 @@ export default function ProfilePage() {
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null);
const [loading, setLoading] = useState(true);
const [favorites, setFavorites] = useState<FavoriteRecord[]>([]);
const [favLoading, setFavLoading] = useState(false);
const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined);
const [editingUsername, setEditingUsername] = useState(false);
const [newUsername, setNewUsername] = useState("");
@@ -87,17 +87,6 @@ export default function ProfilePage() {
.finally(() => setLoading(false));
}, [router]);
useEffect(() => {
if (!userId) return;
setFavLoading(true);
fetch(`/api/user/favorite?userId=${userId}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => setFavorites(Array.isArray(data) ? data : []))
.catch((e) => { console.error("ProfilePage: fetch favorites failed:", e); })
.finally(() => setFavLoading(false));
}, [userId]);
const handleSaveUsername = async () => {
const trimmed = newUsername.trim();
if (trimmed.length < 2 || trimmed.length > 16) {
@@ -220,7 +209,7 @@ export default function ProfilePage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, favoriteId: favId }),
});
setFavorites((f) => f.filter((x) => x.id !== favId));
mutateFavorites((prev) => prev?.filter((x) => x.id !== favId), false);
toast.show("已取消收藏");
} catch {
toast.show("操作失败");