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
+27 -130
View File
@@ -6,7 +6,7 @@
* Fallback path: legacy pipeline (runLegacyPlanGeneration)
*/
import { prisma } from "@/lib/prisma";
import { requireAmapApiKey } from "@/lib/amap";
import { searchPois, getTransitDirection } from "@/lib/amap";
import { generateSchedule, runAgentLoop, type ScheduleContext, type AgentTool } from "@/lib/ai";
import { ApiError } from "@/lib/api";
@@ -110,93 +110,6 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge
return selected;
}
// ---------------------------------------------------------------------------
// Transit segment parser
// ---------------------------------------------------------------------------
function parseTransitSegments(
segments: Record<string, unknown>[],
): { description: string; mode: string } {
const parts: string[] = [];
let hasSubway = false;
let hasBus = false;
for (const seg of segments) {
const bus = seg.bus as { buslines?: Record<string, unknown>[] } | undefined;
if (!bus?.buslines?.length) continue;
for (const line of bus.buslines) {
const rawName = String(line.name ?? "");
const name = rawName.replace(/\(.*?\)$/, "").trim(); // strip terminal info like "(上海南站--江杨北路)"
const viaNum = Number(line.via_num ?? 0);
const isSubway = String(line.type ?? "").includes("地铁") || String(line.type ?? "").includes("轨道");
if (isSubway) hasSubway = true; else hasBus = true;
const dep = (line.departure_stop as Record<string, unknown> | undefined)?.name;
const arr = (line.arrival_stop as Record<string, unknown> | undefined)?.name;
const stops = dep && arr ? ` ${dep}${arr}` : "";
parts.push(viaNum > 0 ? `${name}${stops}(${viaNum}站)` : `${name}${stops}`);
}
}
const mode = hasSubway ? "地铁" : hasBus ? "公交" : "步行";
const description = parts.length > 0 ? parts.join(" → ") : mode;
return { description, mode };
}
// ---------------------------------------------------------------------------
// POI search (shared by both paths)
// ---------------------------------------------------------------------------
export async function searchPois(
query: string,
searchType: string,
anchorLat: number,
anchorLng: number,
): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> {
const apiKey = requireAmapApiKey();
if (searchType === "category") {
const url = new URL("https://restapi.amap.com/v5/place/around");
url.searchParams.set("key", apiKey);
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
url.searchParams.set("keywords", query);
url.searchParams.set("radius", "5000");
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", "8");
const res = await fetch(url.toString());
const data = await res.json();
if (data.status !== "1" || !data.pois?.length) return [];
return mapPois(data.pois);
}
const url = new URL("https://restapi.amap.com/v5/place/text");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", query);
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", "8");
const res = await fetch(url.toString());
const data = await res.json();
if (data.status !== "1" || !data.pois?.length) return [];
return mapPois(data.pois);
}
function mapPois(
pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[],
) {
return pois
.filter((p) => p.location)
.map((p) => {
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
const ratingStr = p.business?.rating;
return {
name: p.name,
address: p.address || "",
lat,
lng,
rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined,
};
});
}
// ---------------------------------------------------------------------------
// Progress messages (kept for legacy path)
@@ -347,29 +260,16 @@ function buildAgentTools(
required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"],
},
execute: async (args) => {
const oLat = Number(args.origin_lat);
const oLng = Number(args.origin_lng);
const dLat = Number(args.dest_lat);
const dLng = Number(args.dest_lng);
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");
try {
const res = await fetch(url.toString());
const data = await res.json();
if (data.status !== "1" || !data.route?.transits?.length) {
return JSON.stringify({ error: "未找到公交路线" });
}
const transit = data.route.transits[0];
const durationMin = Math.ceil(Number(transit.duration) / 60);
const distanceKm = Math.round(Number(data.route.distance) / 100) / 10;
const { description, mode } = parseTransitSegments(transit.segments ?? []);
return JSON.stringify({ durationMin, distanceKm, description, mode });
const result = await getTransitDirection({
originLat: Number(args.origin_lat),
originLng: Number(args.origin_lng),
destLat: Number(args.dest_lat),
destLng: Number(args.dest_lng),
city,
});
if (!result) return JSON.stringify({ error: "未找到公交路线" });
return JSON.stringify(result);
} catch (e) {
console.error("getTravelTimeTool failed:", e);
return JSON.stringify({ error: "路线查询失败" });
@@ -544,9 +444,12 @@ async function runLegacyPlanGeneration(
}),
);
const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) =>
pois.map((p) => ({ ...p, rating: p.rating ?? undefined }));
const candidates: ScheduleContext["candidates"] = {};
for (const result of searchResults) {
candidates[result.query] = result.pois;
candidates[result.query] = toCandidates(result.pois);
}
const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category");
@@ -570,7 +473,7 @@ async function runLegacyPlanGeneration(
}),
);
for (const result of catResults) {
candidates[result.query] = result.pois;
candidates[result.query] = toCandidates(result.pois);
}
}
@@ -626,7 +529,6 @@ async function runLegacyPlanGeneration(
// ---------------------------------------------------------------------------
async function queryTransit(
apiKey: string,
oLng: number,
oLat: number,
dLng: number,
@@ -634,19 +536,15 @@ async function queryTransit(
city: string,
): Promise<{ durationMin: number; description: string } | null> {
try {
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 data = await res.json();
if (data.status !== "1" || !data.route?.transits?.length) return null;
const transit = data.route.transits[0];
const { description } = parseTransitSegments(transit.segments ?? []);
return { durationMin: Math.ceil(Number(transit.duration) / 60), description };
const result = await getTransitDirection({
originLat: oLat,
originLng: oLng,
destLat: dLat,
destLng: dLng,
city,
});
if (!result) return null;
return { durationMin: result.durationMin, description: result.description };
} catch (e) {
console.error("queryTransit failed:", e);
return null;
@@ -669,7 +567,6 @@ async function enrichTransitInfo(
homeLat: number,
homeLng: number,
): Promise<void> {
const apiKey = requireAmapApiKey();
const cityParam = city || "上海";
for (const day of days) {
@@ -680,7 +577,7 @@ async function enrichTransitInfo(
const dLat = Number(items[0].lat);
const dLng = Number(items[0].lng);
if (dLat && dLng) {
const result = await queryTransit(apiKey, homeLng, homeLat, dLng, dLat, cityParam);
const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam);
if (result) {
day.transitFromStart = result.durationMin;
day.transitFromStartDescription = result.description;
@@ -695,7 +592,7 @@ async function enrichTransitInfo(
const dLat = Number(items[i + 1].lat);
const dLng = Number(items[i + 1].lng);
if (!oLat || !oLng || !dLat || !dLng) continue;
const result = await queryTransit(apiKey, oLng, oLat, dLng, dLat, cityParam);
const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam);
if (result) {
items[i].transitToNext = result.durationMin;
items[i].transitDescription = result.description;
@@ -708,7 +605,7 @@ async function enrichTransitInfo(
const oLat = Number(last.lat);
const oLng = Number(last.lng);
if (oLat && oLng) {
const result = await queryTransit(apiKey, oLng, oLat, homeLng, homeLat, cityParam);
const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam);
if (result) {
day.transitToEnd = result.durationMin;
day.transitToEndDescription = result.description;