feat: 交通信息与 AI 解耦,完善出发/回程路线显示

- 从 finalize_plan schema 和 agent 提示词中移除 transit 字段,AI 只负责活动/POI/坐标
- 新增 enrichTransitInfo:计划生成后查询高德 V3 公交 API,计算出发地→首活动、活动间、末活动→出发地三段交通
- parseTransitSegments 增加起终点站显示(去除线路名中的全程终点括号)
- WeekendPlanData 新增 transitFromStart/transitToEnd 字段
- BlindboxPlan 新增出发地和返回出发地交通连接器,传入 startLocationLabel 显示具体地址
- BlindBoxRoom schema 新增 address 字段存完整逆地理地址,city 保留供 API 使用
- 新增 /api/debug/transit 调试端点(仅开发环境)
- agent userPrompt 要求将出发/回程时间计入全天时间预算
This commit is contained in:
2026-03-02 16:35:38 +08:00
parent e5a255a49e
commit 99120a7042
8 changed files with 262 additions and 30 deletions
+4 -1
View File
@@ -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,
});
+90
View File
@@ -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<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}站)`) ?? ["步行"];
}),
})),
});
}
+5 -2
View File
@@ -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();