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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BlindBoxRoom" ADD COLUMN "address" TEXT;
|
||||
@@ -67,6 +67,7 @@ model BlindBoxRoom {
|
||||
name String
|
||||
creatorId String
|
||||
city String?
|
||||
address String?
|
||||
lat Float?
|
||||
lng Float?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}站)`) ?? ["步行"];
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -58,6 +58,8 @@ interface BlindboxPlanProps {
|
||||
onDaysChange?: (newDays: WeekendPlanData[]) => void;
|
||||
/** "lng,lat" 格式,用于 POI 搜索附近优先 */
|
||||
location?: string;
|
||||
/** 出发地名称,用于显示在出发/返回连接器上 */
|
||||
startLocationLabel?: string;
|
||||
onRefine?: (instruction: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<div className="flex items-start gap-1.5 py-2 pl-1">
|
||||
<Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium text-dim">从{startLocationLabel ?? "出发地"}出发</span>
|
||||
{currentDay.transitFromStartDescription && (
|
||||
<span className="text-[10px] leading-snug text-dim">
|
||||
{currentDay.transitFromStartDescription}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-dim/70">约 {currentDay.transitFromStart} 分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentDay.items.map((item, i) => (
|
||||
<motion.div
|
||||
key={`${dayIndex}-${i}`}
|
||||
@@ -483,6 +501,21 @@ export default function BlindboxPlan({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Transit back home after last activity */}
|
||||
{i === currentDay.items.length - 1 && currentDay.transitToEnd != null && (
|
||||
<div className="flex items-start gap-1.5 py-2 pl-1">
|
||||
<Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium text-dim">返回{startLocationLabel ?? "出发地"}</span>
|
||||
{currentDay.transitToEndDescription && (
|
||||
<span className="text-[10px] leading-snug text-dim">
|
||||
{currentDay.transitToEndDescription}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-dim/70">约 {currentDay.transitToEnd} 分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
+119
-27
@@ -125,11 +125,15 @@ function parseTransitSegments(
|
||||
const bus = seg.bus as { buslines?: Record<string, unknown>[] } | 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<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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>[];
|
||||
summary: string;
|
||||
transitFromStart?: number;
|
||||
transitFromStartDescription?: string;
|
||||
transitToEnd?: number;
|
||||
transitToEndDescription?: string;
|
||||
};
|
||||
|
||||
async function enrichTransitInfo(
|
||||
days: EnrichDay[],
|
||||
city: string,
|
||||
homeLat: number,
|
||||
homeLng: number,
|
||||
): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -123,6 +123,14 @@ export interface WeekendPlanData {
|
||||
date: string;
|
||||
items: PlanItem[];
|
||||
summary: string;
|
||||
/** 从出发地到当天第一个活动的交通时间(分钟) */
|
||||
transitFromStart?: number;
|
||||
/** 从出发地到当天第一个活动的交通方式描述 */
|
||||
transitFromStartDescription?: string;
|
||||
/** 从当天最后一个活动返回出发地的交通时间(分钟) */
|
||||
transitToEnd?: number;
|
||||
/** 返回出发地的交通方式描述 */
|
||||
transitToEndDescription?: string;
|
||||
}
|
||||
|
||||
export interface ContractRecord {
|
||||
|
||||
Reference in New Issue
Block a user