diff --git a/prisma/migrations/20260302000000_add_room_address/migration.sql b/prisma/migrations/20260302000000_add_room_address/migration.sql new file mode 100644 index 0000000..aa73c88 --- /dev/null +++ b/prisma/migrations/20260302000000_add_room_address/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BlindBoxRoom" ADD COLUMN "address" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a46dd9..63a42de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -67,6 +67,7 @@ model BlindBoxRoom { name String creatorId String city String? + address String? lat Float? lng Float? createdAt DateTime @default(now()) diff --git a/src/app/api/blindbox/room/[code]/route.ts b/src/app/api/blindbox/room/[code]/route.ts index 62d1f30..9fb8ffe 100644 --- a/src/app/api/blindbox/room/[code]/route.ts +++ b/src/app/api/blindbox/room/[code]/route.ts @@ -15,6 +15,7 @@ export const GET = apiHandler(async (_req, { params }) => { name: room.name, creatorId: room.creatorId, city: room.city, + address: room.address, lat: room.lat, lng: room.lng, poolCount: room._count.ideas, @@ -27,7 +28,7 @@ export const GET = apiHandler(async (_req, { params }) => { export const PATCH = apiHandler(async (req, { params }) => { const { code } = await params; - const { userId, city, lat, lng } = await req.json(); + const { userId, city, address, lat, lng } = await req.json(); requireUserId(userId); @@ -51,6 +52,7 @@ export const PATCH = apiHandler(async (req, { params }) => { where: { id: room.id }, data: { city: typeof city === "string" ? city.trim() : null, + address: typeof address === "string" ? address.trim() : null, lat: numLat, lng: numLng, }, @@ -58,6 +60,7 @@ export const PATCH = apiHandler(async (req, { params }) => { return NextResponse.json({ city: updated.city, + address: updated.address, lat: updated.lat, lng: updated.lng, }); diff --git a/src/app/api/debug/transit/route.ts b/src/app/api/debug/transit/route.ts new file mode 100644 index 0000000..3bd6622 --- /dev/null +++ b/src/app/api/debug/transit/route.ts @@ -0,0 +1,90 @@ +/** + * Debug endpoint: inspect raw Amap transit API response + parsed result. + * GET /api/debug/transit?olat=31.23&olng=121.47&dlat=31.20&dlng=121.50&city=上海 + * Only available in development. + */ +import { NextRequest, NextResponse } from "next/server"; +import { requireAmapApiKey } from "@/lib/amap"; + +export async function GET(req: NextRequest) { + if (process.env.NODE_ENV !== "development") { + return NextResponse.json({ error: "only available in development" }, { status: 403 }); + } + + const { searchParams } = req.nextUrl; + const oLat = Number(searchParams.get("olat")); + const oLng = Number(searchParams.get("olng")); + const dLat = Number(searchParams.get("dlat")); + const dLng = Number(searchParams.get("dlng")); + const city = searchParams.get("city") ?? "上海"; + + if (!oLat || !oLng || !dLat || !dLng) { + 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[]) { + const bus = seg.bus as { buslines?: Record[] } | 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 | undefined)?.name, + arrival: (l.arrival_stop as Record | 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) => ({ + durationMin: Math.ceil(Number(t.duration) / 60), + segments: (t.segments as Record[] ?? []).map((s) => { + const b = s.bus as { buslines?: Record[] } | undefined; + return b?.buslines?.map((l) => `${l.name}(${l.via_num}站)`) ?? ["步行"]; + }), + })), + }); +} diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index 5310857..18921b6 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -43,6 +43,7 @@ interface RoomInfo { name: string; creatorId: string; city: string | null; + address: string | null; lat: number | null; lng: number | null; poolCount: number; @@ -328,15 +329,16 @@ export default function BlindboxRoomPage() { const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`); const regeo = regeoRes.ok ? await regeoRes.json() : {}; const cityName = regeo.name || "未知位置"; + const addressLabel = regeo.formatted || cityName; const patchRes = await fetch(`/api/blindbox/room/${room.code}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: profile.id, city: cityName, lat, lng }), + body: JSON.stringify({ userId: profile.id, city: cityName, address: addressLabel, lat, lng }), }); if (!patchRes.ok) throw new Error("保存位置失败"); - setRoom((prev) => prev ? { ...prev, city: cityName, lat, lng } : prev); + setRoom((prev) => prev ? { ...prev, city: cityName, address: addressLabel, lat, lng } : prev); toast.show("位置已设置"); } catch { toast.show("获取位置失败,请允许定位权限"); @@ -1132,6 +1134,7 @@ export default function BlindboxRoomPage() { onDaysChange={handlePlanDaysChange} onRefine={handleRefine} location={room.lng != null && room.lat != null ? `${room.lng},${room.lat}` : undefined} + startLocationLabel={room.address ?? room.city ?? undefined} onAccept={async () => { setPlanAccepted(true); fireConfetti(); diff --git a/src/components/BlindboxPlan.tsx b/src/components/BlindboxPlan.tsx index a214d16..08bb418 100644 --- a/src/components/BlindboxPlan.tsx +++ b/src/components/BlindboxPlan.tsx @@ -58,6 +58,8 @@ interface BlindboxPlanProps { onDaysChange?: (newDays: WeekendPlanData[]) => void; /** "lng,lat" 格式,用于 POI 搜索附近优先 */ location?: string; + /** 出发地名称,用于显示在出发/返回连接器上 */ + startLocationLabel?: string; onRefine?: (instruction: string) => Promise; } @@ -275,6 +277,7 @@ export default function BlindboxPlan({ regenerating, onDaysChange, location, + startLocationLabel, onRefine, }: BlindboxPlanProps) { const [dayIndex, setDayIndex] = useState(0); @@ -452,6 +455,21 @@ export default function BlindboxPlan({ items={currentDay.items.map((_, i) => `${dayIndex}-${i}`)} strategy={verticalListSortingStrategy} > + {/* Transit from home to first activity */} + {currentDay.transitFromStart != null && ( +
+ +
+ 从{startLocationLabel ?? "出发地"}出发 + {currentDay.transitFromStartDescription && ( + + {currentDay.transitFromStartDescription} + + )} + 约 {currentDay.transitFromStart} 分钟 +
+
+ )} {currentDay.items.map((item, i) => ( )} + {/* Transit back home after last activity */} + {i === currentDay.items.length - 1 && currentDay.transitToEnd != null && ( +
+ +
+ 返回{startLocationLabel ?? "出发地"} + {currentDay.transitToEndDescription && ( + + {currentDay.transitToEndDescription} + + )} + 约 {currentDay.transitToEnd} 分钟 +
+
+ )}
))} diff --git a/src/lib/blindboxPlanGen.ts b/src/lib/blindboxPlanGen.ts index c53404c..9399328 100644 --- a/src/lib/blindboxPlanGen.ts +++ b/src/lib/blindboxPlanGen.ts @@ -125,11 +125,15 @@ function parseTransitSegments( const bus = seg.bus as { buslines?: Record[] } | undefined; if (!bus?.buslines?.length) continue; for (const line of bus.buslines) { - const name = String(line.name ?? ""); + 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; - parts.push(viaNum > 0 ? `${name}(${viaNum}站)` : name); + const dep = (line.departure_stop as Record | undefined)?.name; + const arr = (line.arrival_stop as Record | undefined)?.name; + const stops = dep && arr ? ` ${dep}→${arr}` : ""; + parts.push(viaNum > 0 ? `${name}${stops}(${viaNum}站)` : `${name}${stops}`); } } @@ -231,9 +235,8 @@ const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下 2. 根据时间、多样性、强度平衡选出合适的活动组合 3. 为每个活动 search_poi 找到具体地点 4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜 -5. 用 get_travel_time 检查关键路段的公共交通时间,如果某段超过 45 分钟考虑换更近的地点 -6. 综合地理位置、时间安排、活动特点,规划最优路线 -7. 确认无误后 finalize_plan 提交,每个活动填写 transit_to_next_minutes 和 transit_to_next_description(直接复制 get_travel_time 返回的 durationMin 和 description,最后一项不填) +5. 用 get_travel_time 检查关键路段,如果某段超过 45 分钟考虑换更近的地点 +6. 确认地点合理后 finalize_plan 提交 规划原则: - 地理位置相近,最小化移动距离 @@ -257,8 +260,6 @@ interface FinalizePlanDay { lng: number; duration: number; reason: string; - transit_to_next_minutes?: number; - transit_to_next_description?: string; }[]; summary: string; } @@ -350,12 +351,13 @@ function buildAgentTools( const dLat = Number(args.dest_lat); const dLng = Number(args.dest_lng); const apiKey = requireAmapApiKey(); - const url = new URL("https://restapi.amap.com/v5/direction/transit/integrated"); + 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("show_fields", "cost"); + url.searchParams.set("cityd", city); + url.searchParams.set("output", "json"); try { const res = await fetch(url.toString()); const data = await res.json(); @@ -404,14 +406,6 @@ function buildAgentTools( lng: { type: "number" }, duration: { type: "number", description: "时长(分钟)" }, reason: { type: "string", description: "选择理由" }, - transit_to_next_minutes: { - type: "number", - description: "从本活动结束后到下一活动的预计交通时间(分钟)。最后一个活动不填", - }, - transit_to_next_description: { - type: "string", - description: "到下一活动的交通方式描述,直接使用 get_travel_time 返回的 description 字段值,例如:地铁2号线(6站) → 地铁9号线(3站)。最后一个活动不填", - }, }, required: ["time", "activity", "poi", "address", "lat", "lng", "duration", "reason"], }, @@ -445,7 +439,8 @@ async function runAgentPlanGeneration( const userPrompt = `帮我规划行程。 可用时间:${at.date},${at.startHour}:00 - ${at.endHour}:00 -用户位置:纬度 ${room.lat},经度 ${room.lng} +出发地(家):纬度 ${room.lat},经度 ${room.lng} +注意:第一个活动需要从出发地出发,最后一个活动结束后需要返回出发地,请用 get_travel_time 评估出发/回程时间并计入全天时间预算(确保最后一个活动结束时间 + 回程时间 ≤ ${at.endHour}:00)。 请开始规划。`; const result = await runAgentLoop({ @@ -478,14 +473,6 @@ async function runAgentPlanGeneration( lng: Number(item.lng) || 0, duration: Number(item.duration) || 60, reason: String(item.reason ?? ""), - ...(item.transit_to_next_minutes != null && Number(item.transit_to_next_minutes) > 0 - ? { - transitToNext: Math.round(Number(item.transit_to_next_minutes)), - ...(item.transit_to_next_description - ? { transitDescription: String(item.transit_to_next_description) } - : {}), - } - : {}), })) : [], summary: String(day.summary ?? ""), @@ -630,6 +617,102 @@ async function runLegacyPlanGeneration( // --------------------------------------------------------------------------- // Public entry point (signature unchanged) +// --------------------------------------------------------------------------- +// Post-processing: compute real transit info from Amap and store in plan +// --------------------------------------------------------------------------- + +async function queryTransit( + apiKey: string, + oLng: number, + oLat: number, + dLng: number, + dLat: number, + 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 }; + } catch { + return null; + } +} + +type EnrichDay = { + date: string; + items: Record[]; + summary: string; + transitFromStart?: number; + transitFromStartDescription?: string; + transitToEnd?: number; + transitToEndDescription?: string; +}; + +async function enrichTransitInfo( + days: EnrichDay[], + city: string, + homeLat: number, + homeLng: number, +): Promise { + const apiKey = requireAmapApiKey(); + const cityParam = city || "上海"; + + for (const day of days) { + const items = day.items; + + // From home to first activity + if (items.length > 0 && homeLat && homeLng) { + 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); + if (result) { + day.transitFromStart = result.durationMin; + day.transitFromStartDescription = result.description; + } + } + } + + // Between consecutive activities + for (let i = 0; i < items.length - 1; i++) { + const oLat = Number(items[i].lat); + const oLng = Number(items[i].lng); + 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); + if (result) { + items[i].transitToNext = result.durationMin; + items[i].transitDescription = result.description; + } + } + + // From last activity back home + if (items.length > 0 && homeLat && homeLng) { + const last = items[items.length - 1]; + const oLat = Number(last.lat); + const oLng = Number(last.lng); + if (oLat && oLng) { + const result = await queryTransit(apiKey, oLng, oLat, homeLng, homeLat, cityParam); + if (result) { + day.transitToEnd = result.durationMin; + day.transitToEndDescription = result.description; + } + } + } + } +} + // --------------------------------------------------------------------------- export async function runPlanGeneration( @@ -698,7 +781,16 @@ export async function runPlanGeneration( days = legacyResult.days; } - // 3. Save to DB (shared) + // 3. Compute real transit info server-side (best-effort, errors are swallowed internally) + onProgress?.("正在查询交通信息..."); + await enrichTransitInfo( + days as EnrichDay[], + room.city ?? "上海", + room.lat, + room.lng, + ); + + // 4. Save to DB (shared) const plan = await prisma.weekendPlan.create({ data: { roomId, diff --git a/src/types/index.ts b/src/types/index.ts index ff7b713..fc1660d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -123,6 +123,14 @@ export interface WeekendPlanData { date: string; items: PlanItem[]; summary: string; + /** 从出发地到当天第一个活动的交通时间(分钟) */ + transitFromStart?: number; + /** 从出发地到当天第一个活动的交通方式描述 */ + transitFromStartDescription?: string; + /** 从当天最后一个活动返回出发地的交通时间(分钟) */ + transitToEnd?: number; + /** 返回出发地的交通方式描述 */ + transitToEndDescription?: string; } export interface ContractRecord {