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:
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
>
|
||||
点击重试
|
||||
|
||||
@@ -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("操作失败");
|
||||
|
||||
Reference in New Issue
Block a user