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:
+324
@@ -1,7 +1,331 @@
|
||||
/**
|
||||
* Unified Amap (高德地图) API client.
|
||||
* All external calls go through amapFetch() with consistent timeout + error handling.
|
||||
*/
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
const AMAP_TIMEOUT_MS = 8000;
|
||||
|
||||
export function requireAmapApiKey(): string {
|
||||
const key = process.env.AMAP_API_KEY;
|
||||
if (!key) throw new ApiError("服务配置异常,请稍后重试", 500);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function amapFetch(url: URL): Promise<Record<string, unknown>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url.toString(), {
|
||||
signal: AbortSignal.timeout(AMAP_TIMEOUT_MS),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared POI mapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RawPoi {
|
||||
id?: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
location?: string;
|
||||
type?: string;
|
||||
business?: { rating?: string; cost?: string; tel?: string };
|
||||
}
|
||||
|
||||
export interface PoiResult {
|
||||
id?: string;
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
rating?: number | null;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
function mapPois(pois: RawPoi[], opts?: { includeCost?: boolean }): PoiResult[] {
|
||||
return pois
|
||||
.filter((p) => p.location)
|
||||
.map((p) => {
|
||||
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
|
||||
const ratingStr = p.business?.rating;
|
||||
const rating =
|
||||
ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null;
|
||||
const result: PoiResult = {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
address: p.address || "",
|
||||
lat,
|
||||
lng,
|
||||
rating,
|
||||
};
|
||||
if (opts?.includeCost) {
|
||||
const costStr = p.business?.cost;
|
||||
result.cost =
|
||||
costStr && costStr !== "[]" && costStr !== "0"
|
||||
? Number(costStr)
|
||||
: null;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POI search — text (v5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchPlaceText(params: {
|
||||
keywords: string;
|
||||
city?: string;
|
||||
types?: string;
|
||||
location?: string;
|
||||
pageSize?: number;
|
||||
}): Promise<PoiResult[]> {
|
||||
const url = new URL("https://restapi.amap.com/v5/place/text");
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("keywords", params.keywords);
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", String(params.pageSize ?? 10));
|
||||
if (params.city) url.searchParams.set("region", params.city);
|
||||
if (params.types) url.searchParams.set("types", params.types);
|
||||
if (params.location) url.searchParams.set("location", params.location);
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1" || !(data.pois as RawPoi[] | undefined)?.length) {
|
||||
return [];
|
||||
}
|
||||
return mapPois(data.pois as RawPoi[], { includeCost: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POI search — around (v5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchPlaceAround(params: {
|
||||
keywords: string;
|
||||
location: string;
|
||||
radius?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<PoiResult[]> {
|
||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("location", params.location);
|
||||
url.searchParams.set("keywords", params.keywords);
|
||||
url.searchParams.set("radius", String(params.radius ?? 5000));
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", String(params.pageSize ?? 8));
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1" || !(data.pois as RawPoi[] | undefined)?.length) {
|
||||
return [];
|
||||
}
|
||||
return mapPois(data.pois as RawPoi[]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input tips (v3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InputTipResult {
|
||||
id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export async function getInputTips(params: {
|
||||
keywords: string;
|
||||
location?: string;
|
||||
}): Promise<InputTipResult[]> {
|
||||
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("keywords", params.keywords);
|
||||
url.searchParams.set("datatype", "poi");
|
||||
if (params.location) url.searchParams.set("location", params.location);
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1" || !data.tips) return [];
|
||||
|
||||
return (
|
||||
data.tips as {
|
||||
id: string;
|
||||
name: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
location?: string;
|
||||
}[]
|
||||
)
|
||||
.filter((t) => t.location && t.location !== "")
|
||||
.slice(0, 8)
|
||||
.map((t) => {
|
||||
const [lng, lat] = t.location!.split(",").map(Number);
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
district: t.district || "",
|
||||
address: t.address || "",
|
||||
lat,
|
||||
lng,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reverse geocode (v3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RegeoResult {
|
||||
name: string | null;
|
||||
formatted: string | null;
|
||||
}
|
||||
|
||||
export async function reverseGeocode(params: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}): Promise<RegeoResult> {
|
||||
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("location", `${params.lng},${params.lat}`);
|
||||
url.searchParams.set("extensions", "base");
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1" || !data.regeocode) {
|
||||
return { name: null, formatted: null };
|
||||
}
|
||||
|
||||
const regeocode = data.regeocode as Record<string, unknown>;
|
||||
const comp = regeocode.addressComponent as Record<string, unknown> | undefined;
|
||||
const district = (comp?.district || comp?.city || "") as string;
|
||||
const township = (comp?.township || "") as string;
|
||||
const neighborhood = (
|
||||
(comp?.neighborhood as Record<string, unknown> | undefined)?.name || ""
|
||||
) as string;
|
||||
|
||||
const name = [district, township, neighborhood]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
return {
|
||||
name: name || (regeocode.formatted_address as string) || null,
|
||||
formatted: (regeocode.formatted_address as string) || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transit direction (v3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TransitResult {
|
||||
durationMin: number;
|
||||
distanceKm: number;
|
||||
description: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export 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();
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function getTransitDirection(params: {
|
||||
originLat: number;
|
||||
originLng: number;
|
||||
destLat: number;
|
||||
destLng: number;
|
||||
city: string;
|
||||
cityDest?: string;
|
||||
}): Promise<TransitResult | null> {
|
||||
const url = new URL(
|
||||
"https://restapi.amap.com/v3/direction/transit/integrated",
|
||||
);
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("origin", `${params.originLng},${params.originLat}`);
|
||||
url.searchParams.set(
|
||||
"destination",
|
||||
`${params.destLng},${params.destLat}`,
|
||||
);
|
||||
url.searchParams.set("city", params.city);
|
||||
url.searchParams.set("cityd", params.cityDest ?? params.city);
|
||||
url.searchParams.set("output", "json");
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1") return null;
|
||||
|
||||
const route = data.route as Record<string, unknown> | undefined;
|
||||
const transits = route?.transits as Record<string, unknown>[] | undefined;
|
||||
if (!transits?.length) return null;
|
||||
|
||||
const transit = transits[0];
|
||||
const durationMin = Math.ceil(Number(transit.duration) / 60);
|
||||
const distanceKm =
|
||||
Math.round(Number(route!.distance) / 100) / 10;
|
||||
const { description, mode } = parseTransitSegments(
|
||||
(transit.segments as Record<string, unknown>[]) ?? [],
|
||||
);
|
||||
return { durationMin, distanceKm, description, mode };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience: POI search used by plan generation (dispatches text vs around)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchPois(
|
||||
query: string,
|
||||
searchType: string,
|
||||
anchorLat: number,
|
||||
anchorLng: number,
|
||||
): Promise<PoiResult[]> {
|
||||
const location = `${anchorLng},${anchorLat}`;
|
||||
if (searchType === "category") {
|
||||
return searchPlaceAround({
|
||||
keywords: query,
|
||||
location,
|
||||
pageSize: 8,
|
||||
});
|
||||
}
|
||||
return searchPlaceText({
|
||||
keywords: query,
|
||||
location,
|
||||
pageSize: 8,
|
||||
});
|
||||
}
|
||||
|
||||
+27
-130
@@ -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;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Shared SWR fetcher with standard error handling.
|
||||
*/
|
||||
|
||||
export class FetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FetchError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetcher<T = unknown>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new FetchError(
|
||||
res.status === 404 ? "NOT_FOUND" : res.status === 401 ? "UNAUTHORIZED" : "FETCH_ERROR",
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function guessCategory(activity: string): string | null {
|
||||
const lower = activity.toLowerCase();
|
||||
if (/吃|餐|饭|火锅|烧烤|面|菜|厨|食/.test(lower)) return "dining";
|
||||
if (/手作|工坊|烘焙|插花|陶艺|DIY|体验/.test(lower)) return "experience";
|
||||
if (/露营|徒步|赶海|农场|自然|野|营地/.test(lower)) return "nature";
|
||||
if (/公园|山|湖|海|户外|骑/.test(lower)) return "outdoor";
|
||||
if (/电影|KTV|密室|游戏|桌游|剧/.test(lower)) return "entertainment";
|
||||
if (/逛街|购物|商场|买/.test(lower)) return "shopping";
|
||||
if (/运动|健身|球|跑|游泳|瑜伽/.test(lower)) return "sports";
|
||||
if (/博物馆|展览|美术|书/.test(lower)) return "culture";
|
||||
if (/咖啡|茶|SPA|按摩|下午茶/.test(lower)) return "relaxation";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatDuration(minutes: number): string {
|
||||
if (minutes >= 60) {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h${m}min` : `${h}h`;
|
||||
}
|
||||
return `${minutes}min`;
|
||||
}
|
||||
Reference in New Issue
Block a user