feat: AI 辅助修改行程(自然语言调整 + 单活动替代推荐)
- 新增 refinePlan / suggestAlternativeItems 到 ai.ts - 新增 POST /api/blindbox/plan/refine(整体行程调整) - 新增 POST /api/blindbox/plan/suggest-item(单活动 AI 替代 + POI 搜索) - BlindboxPlan 底部新增自然语言输入框(方案 A) - 编辑 modal 内新增 AI 推荐替代方案卡片(方案 B) - export searchPois 供 suggest-item 路由复用
This commit is contained in:
@@ -38,6 +38,15 @@ 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;
|
||||
@@ -49,6 +58,7 @@ interface BlindboxPlanProps {
|
||||
onDaysChange?: (newDays: WeekendPlanData[]) => void;
|
||||
/** "lng,lat" 格式,用于 POI 搜索附近优先 */
|
||||
location?: string;
|
||||
onRefine?: (instruction: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function guessCategory(activity: string): string | null {
|
||||
@@ -265,6 +275,7 @@ export default function BlindboxPlan({
|
||||
regenerating,
|
||||
onDaysChange,
|
||||
location,
|
||||
onRefine,
|
||||
}: BlindboxPlanProps) {
|
||||
const [dayIndex, setDayIndex] = useState(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -275,6 +286,10 @@ export default function BlindboxPlan({
|
||||
|
||||
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" });
|
||||
@@ -314,6 +329,7 @@ export default function BlindboxPlan({
|
||||
onDaysChange(newDays);
|
||||
setEditingItem(null);
|
||||
setDraft(null);
|
||||
setAltSuggestions([]);
|
||||
}
|
||||
|
||||
// Cross-day move: immediately call onDaysChange
|
||||
@@ -338,6 +354,34 @@ export default function BlindboxPlan({
|
||||
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 (
|
||||
@@ -446,6 +490,27 @@ export default function BlindboxPlan({
|
||||
|
||||
{/* 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">
|
||||
@@ -513,13 +578,13 @@ export default function BlindboxPlan({
|
||||
</div>
|
||||
|
||||
{/* Edit item modal */}
|
||||
<Modal open={!!editingItem && !!draft} onClose={() => { setEditingItem(null); setDraft(null); }} variant="sheet">
|
||||
<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); }}
|
||||
onClick={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }}
|
||||
className="text-muted active:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
@@ -568,6 +633,38 @@ export default function BlindboxPlan({
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user