99120a7042
- 从 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 要求将出发/回程时间计入全天时间预算
743 lines
27 KiB
TypeScript
743 lines
27 KiB
TypeScript
"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>
|
||
);
|
||
}
|