Files
no-whatever/src/components/BlindboxPlan.tsx
T
kurihada 99120a7042 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 要求将出发/回程时间计入全天时间预算
2026-03-02 16:35:38 +08:00

743 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MapPin,
Clock,
Navigation,
Share2,
RefreshCw,
Sparkles,
ChevronRight,
ChevronLeft,
CornerDownLeft,
GripVertical,
Pencil,
X,
Loader2,
} from "lucide-react";
import {
DndContext,
DragEndEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { CategoryBadge } from "@/components/BlindboxMyIdeas";
import Button from "@/components/Button";
import Modal from "@/components/Modal";
import type { WeekendPlanData, PlanItem } from "@/types";
interface AltSuggestion {
activity: string;
poi: string;
address: string;
lat: number;
lng: number;
reason: string;
}
interface BlindboxPlanProps {
days: WeekendPlanData[];
onAccept: () => void;
onRegenerate: () => void;
onShare: () => void;
onBack: () => void;
accepted?: boolean;
regenerating?: boolean;
onDaysChange?: (newDays: WeekendPlanData[]) => void;
/** "lng,lat" 格式,用于 POI 搜索附近优先 */
location?: string;
/** 出发地名称,用于显示在出发/返回连接器上 */
startLocationLabel?: string;
onRefine?: (instruction: string) => Promise<void>;
}
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;
}
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`;
}
interface PoiSuggestion {
id: string;
name: string;
district: string;
address: string;
lat: number;
lng: number;
}
interface PoiSearchFieldProps {
poi: string;
address: string;
onSelect: (s: PoiSuggestion) => void;
location?: string;
}
function PoiSearchField({ poi, address, onSelect, location }: PoiSearchFieldProps) {
const [query, setQuery] = useState(poi);
const [suggestions, setSuggestions] = useState<PoiSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const lastSelectedRef = useRef(poi);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
if (!query.trim() || query === lastSelectedRef.current) {
setSuggestions([]);
setOpen(false);
return;
}
timerRef.current = setTimeout(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ keywords: query });
if (location) params.set("location", location);
const res = await fetch(`/api/location/suggest?${params}`);
if (res.ok) {
const data: PoiSuggestion[] = await res.json();
setSuggestions(data);
setOpen(data.length > 0);
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, 400);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}, [query]);
return (
<div className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索地点名称..."
className="h-9 w-full rounded-lg bg-elevated px-3 pr-8 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
{loading && (
<Loader2 size={14} className="absolute right-2.5 top-2.5 animate-spin text-muted" />
)}
</div>
{open && suggestions.length > 0 && (
<div className="overflow-hidden rounded-lg bg-elevated ring-1 ring-border">
{suggestions.map((s) => (
<button
key={s.id}
type="button"
onClick={() => {
lastSelectedRef.current = s.name;
setQuery(s.name);
setOpen(false);
setSuggestions([]);
onSelect(s);
}}
className="flex w-full flex-col border-b border-border/40 px-3 py-2 text-left last:border-0 active:bg-purple-600/10"
>
<span className="text-xs font-medium text-foreground">{s.name}</span>
<span className="truncate text-[10px] text-muted">{s.district} {s.address}</span>
</button>
))}
</div>
)}
{address && !open && (
<p className="flex items-center gap-1 text-[10px] text-dim">
<MapPin size={9} className="shrink-0" />
{address}
</p>
)}
</div>
);
}
interface SortablePlanItemProps {
id: string;
item: PlanItem;
index: number;
canEdit: boolean;
onEdit: () => void;
}
function SortablePlanItem({ id, item, canEdit, onEdit }: SortablePlanItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} className="relative mb-5">
<div className="absolute -left-6 top-4 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-purple-600/20 ring-2 ring-background">
<div className="h-2 w-2 rounded-full bg-purple-400" />
</div>
<div className="rounded-2xl bg-surface/80 p-4 ring-1 ring-border/80">
<div className="flex items-start gap-2.5">
{canEdit && (
<button
{...attributes}
{...listeners}
className="mt-1 shrink-0 touch-none cursor-grab text-muted/40 active:cursor-grabbing active:text-muted"
aria-label="拖拽排序"
>
<GripVertical size={14} />
</button>
)}
<span className="mt-0.5 min-w-[38px] text-sm font-black text-purple-400">{item.time}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<CategoryBadge category={guessCategory(item.activity)} />
<p className="truncate text-sm font-bold text-heading">{item.activity}</p>
</div>
<div className="mt-2 flex items-center gap-1 text-[11px] text-muted">
<MapPin size={10} className="shrink-0" />
<span className="truncate">{item.poi}</span>
</div>
{item.address && (
<p className="mt-1 truncate text-[10px] text-dim">{item.address}</p>
)}
<div className="mt-2.5 flex items-center gap-3">
<span className="flex items-center gap-1 text-[10px] text-dim">
<Clock size={9} />
{formatDuration(item.duration)}
</span>
{item.lat !== 0 && item.lng !== 0 && (
<a
href={`https://uri.amap.com/marker?position=${item.lng},${item.lat}&name=${encodeURIComponent(item.poi)}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[10px] font-medium text-purple-400/70 active:text-purple-400"
>
<Navigation size={9} />
</a>
)}
</div>
{item.reason && (
<p className="mt-2.5 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-dim italic">
{item.reason}
</p>
)}
</div>
{canEdit && (
<button
onClick={onEdit}
className="mt-0.5 shrink-0 text-muted/40 active:text-purple-400"
aria-label="编辑"
>
<Pencil size={13} />
</button>
)}
</div>
</div>
</div>
);
}
export default function BlindboxPlan({
days,
onAccept,
onRegenerate,
onShare,
onBack,
accepted,
regenerating,
onDaysChange,
location,
startLocationLabel,
onRefine,
}: BlindboxPlanProps) {
const [dayIndex, setDayIndex] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const currentDay = days[dayIndex];
const hasNext = dayIndex < days.length - 1;
const hasPrev = dayIndex > 0;
const canEdit = !!onDaysChange;
const [editingItem, setEditingItem] = useState<{ dayIndex: number; itemIndex: number } | null>(null);
const [draft, setDraft] = useState<PlanItem | null>(null);
const [refineInput, setRefineInput] = useState("");
const [refining, setRefining] = useState(false);
const [suggestingAlt, setSuggestingAlt] = useState(false);
const [altSuggestions, setAltSuggestions] = useState<AltSuggestion[]>([]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [dayIndex]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id || !onDaysChange) return;
const items = days[dayIndex].items;
const ids = items.map((_, i) => `${dayIndex}-${i}`);
const oldIndex = ids.indexOf(String(active.id));
const newIndex = ids.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
const newDays = days.map((day, di) =>
di === dayIndex ? { ...day, items: arrayMove(day.items, oldIndex, newIndex) } : day,
);
onDaysChange(newDays);
}
function handleSaveDraft() {
if (!draft || !editingItem || !onDaysChange) return;
const newDays = days.map((day, di) =>
di !== editingItem.dayIndex
? day
: {
...day,
items: day.items.map((item, ii) =>
ii === editingItem.itemIndex ? draft : item,
),
},
);
onDaysChange(newDays);
setEditingItem(null);
setDraft(null);
setAltSuggestions([]);
}
// Cross-day move: immediately call onDaysChange
function handleMoveToDayIndex(targetDayIndex: number) {
if (!draft || !editingItem || !onDaysChange) return;
if (targetDayIndex === editingItem.dayIndex) return;
const newDays = days.map((day, di) => {
if (di === editingItem.dayIndex) {
return {
...day,
items: day.items.filter((_, ii) => ii !== editingItem.itemIndex),
};
}
if (di === targetDayIndex) {
return { ...day, items: [...day.items, draft] };
}
return day;
});
onDaysChange(newDays);
setEditingItem(null);
setDraft(null);
}
async function handleRefine() {
if (!refineInput.trim() || refining || !onRefine) return;
setRefining(true);
try {
await onRefine(refineInput.trim());
setRefineInput("");
} finally {
setRefining(false);
}
}
async function handleSuggestAlt() {
if (!draft || suggestingAlt) return;
setSuggestingAlt(true);
try {
const res = await fetch("/api/blindbox/plan/suggest-item", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activity: draft.activity, time: draft.time, location }),
});
if (!res.ok) return;
const data = await res.json();
setAltSuggestions(data.suggestions ?? []);
} finally {
setSuggestingAlt(false);
}
}
if (!currentDay) return null;
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* Day header — sticky top */}
<div className="shrink-0 pb-3 text-center">
<motion.div
className="inline-flex items-center gap-1.5 rounded-full bg-purple-600/15 px-3 py-1 text-xs font-bold text-purple-400"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Sparkles size={12} />
{currentDay.date} ·
</motion.div>
{days.length > 1 && (
<div className="mt-2 flex items-center justify-center gap-1.5">
{days.map((day, i) => (
<button
key={day.date}
onClick={() => setDayIndex(i)}
className={`rounded-full transition-all ${
i === dayIndex
? "h-1.5 w-5 bg-purple-400"
: "h-1.5 w-1.5 bg-purple-400/25"
}`}
/>
))}
</div>
)}
{currentDay.summary && (
<motion.p
className="mt-2 text-xs text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
{currentDay.summary}
</motion.p>
)}
</div>
{/* Scrollable timeline */}
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto scrollbar-none"
>
<AnimatePresence mode="wait">
<motion.div
key={dayIndex}
className="relative mx-auto max-w-sm px-4 pb-2 pl-9"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.25 }}
>
<div className="absolute left-[21px] top-2 bottom-2 w-px bg-purple-500/20" />
<DndContext
key={dayIndex}
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
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}`}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + i * 0.08 }}
>
<SortablePlanItem
id={`${dayIndex}-${i}`}
item={item}
index={i}
canEdit={canEdit}
onEdit={() => {
setEditingItem({ dayIndex, itemIndex: i });
setDraft({ ...item });
}}
/>
{/* Transit connector to next activity */}
{item.transitToNext != null && i < currentDay.items.length - 1 && (
<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">
{item.transitDescription && (
<span className="text-[10px] leading-snug text-dim">
{item.transitDescription}
</span>
)}
<span className="text-[10px] text-dim/70"> {item.transitToNext} </span>
</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>
</DndContext>
</motion.div>
</AnimatePresence>
{/* Back to pool — at end of scroll content */}
<div className="mt-6 flex justify-center pb-4">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-xs font-medium text-muted active:text-foreground"
>
<CornerDownLeft size={12} />
</button>
</div>
</div>
{/* Fixed bottom bar — actions + day navigation */}
<div className="shrink-0 border-t border-border/40 bg-background/80 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur-lg">
{/* Refine input (Plan A) */}
{onRefine && (
<div className="mx-auto flex max-w-sm items-center gap-2 px-4 pb-2">
<input
value={refineInput}
onChange={(e) => setRefineInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleRefine(); }}
placeholder="告诉 AI 你想怎么改..."
disabled={refining}
className="h-9 flex-1 rounded-xl bg-surface px-3 text-sm outline-none ring-1 ring-border focus:ring-purple-600 disabled:opacity-50"
/>
<button
onClick={handleRefine}
disabled={!refineInput.trim() || refining}
className="flex h-9 w-9 items-center justify-center rounded-xl bg-purple-600/15 text-purple-400 disabled:opacity-30"
>
{refining ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
</button>
</div>
)}
{/* Day navigation */}
{days.length > 1 && (
<div className="mx-auto mb-2.5 flex max-w-sm items-center justify-center gap-2 px-4">
{hasPrev && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={() => setDayIndex(dayIndex - 1)}
className="flex items-center gap-1 rounded-full bg-surface px-3 py-1.5 text-[11px] font-bold text-purple-400 ring-1 ring-border/60 active:bg-elevated"
>
<ChevronLeft size={12} />
{days[dayIndex - 1].date}
</motion.button>
)}
<span className="text-[10px] text-dim">
{dayIndex + 1} / {days.length}
</span>
{hasNext && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={() => setDayIndex(dayIndex + 1)}
className="flex items-center gap-1 rounded-full bg-purple-600/15 px-3 py-1.5 text-[11px] font-bold text-purple-400 active:bg-purple-600/25"
>
{days[dayIndex + 1].date}
<ChevronRight size={12} />
</motion.button>
)}
</div>
)}
{/* Action buttons */}
<div className="mx-auto flex max-w-sm items-center justify-center gap-3 px-4">
{accepted ? (
<Button
onClick={onShare}
variant="purple"
shape="pill"
icon={<Share2 size={14} />}
>
</Button>
) : (
<>
<Button
onClick={onAccept}
variant="purple"
shape="pill"
icon={<Sparkles size={14} />}
>
</Button>
<Button
onClick={onRegenerate}
variant="secondary"
shape="pill"
loading={regenerating}
icon={<RefreshCw size={14} />}
>
</Button>
</>
)}
</div>
</div>
{/* Edit item modal */}
<Modal open={!!editingItem && !!draft} onClose={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }} variant="sheet">
{draft && editingItem && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-heading"></h3>
<button
onClick={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }}
className="text-muted active:text-foreground"
>
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<input
type="text"
value={draft.activity}
onChange={(e) => setDraft({ ...draft, activity: e.target.value })}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
</label>
<div className="flex gap-2">
<label className="flex flex-1 flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<input
type="time"
value={draft.time}
onChange={(e) => setDraft({ ...draft, time: e.target.value })}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
</label>
<label className="flex flex-1 flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<input
type="number"
step={15}
min={15}
value={draft.duration}
onChange={(e) => setDraft({ ...draft, duration: Number(e.target.value) })}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
/>
</label>
</div>
<PoiSearchField
key={`${editingItem.dayIndex}-${editingItem.itemIndex}`}
poi={draft.poi}
address={draft.address}
location={location}
onSelect={(s) => setDraft({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })}
/>
{/* AI 推荐替代 (Plan B) */}
{altSuggestions.length === 0 ? (
<button
onClick={handleSuggestAlt}
disabled={suggestingAlt}
className="flex items-center gap-1.5 self-start text-xs font-medium text-purple-400/70 active:text-purple-400 disabled:opacity-40"
>
{suggestingAlt
? <><Loader2 size={12} className="animate-spin" /> ...</>
: <><Sparkles size={12} /> AI </>}
</button>
) : (
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium text-muted"></span>
{altSuggestions.map((alt, i) => (
<button
key={i}
onClick={() => {
setDraft({ ...draft, ...alt });
setAltSuggestions([]);
}}
className="rounded-lg bg-elevated px-3 py-2 text-left ring-1 ring-border active:ring-purple-500"
>
<p className="text-xs font-bold text-heading">{alt.activity}</p>
<p className="truncate text-[10px] text-muted">{alt.poi}</p>
<p className="text-[10px] text-dim italic">{alt.reason}</p>
</button>
))}
<button onClick={() => setAltSuggestions([])} className="self-start text-[10px] text-dim"></button>
</div>
)}
{days.length > 1 && (
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted"></span>
<select
value={editingItem.dayIndex}
onChange={(e) => handleMoveToDayIndex(Number(e.target.value))}
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
>
{days.map((day, i) => (
<option key={day.date} value={i}>
{day.date}
{i === editingItem.dayIndex ? "(当前)" : ""}
</option>
))}
</select>
</label>
)}
</div>
<Button onClick={handleSaveDraft} variant="purple" shape="pill">
</Button>
</div>
)}
</Modal>
</div>
);
}