feat: AI 周末行程规划 — DeepSeek 智能排期 + 高德 POI + 多日翻页

- 接入 DeepSeek API,提交想法时自动 AI 打标(品类/时段/时长/搜索策略)
- 新增行程规划 API:智能选取想法 → 高德 POI 搜索 → AI 生成最优行程
- 支持多日计划("整个周末"拆分周六/周日,并行 AI 调用)
- 行程展示逐日翻页,时间线可滚动,操作按钮固定底部
- 分享卡适配多日格式,支持图片保存与原生分享
- Prisma schema 新增 WeekendPlan 模型及 BlindBoxIdea AI 标签字段
- Jenkinsfile 集成 DEEPSEEK_API_KEY 环境变量
This commit is contained in:
2026-02-27 01:51:47 +08:00
parent 8c6da410ca
commit 9c680ec11e
16 changed files with 1721 additions and 70 deletions
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MapPin,
Clock,
Navigation,
Share2,
RefreshCw,
Sparkles,
ChevronRight,
ChevronLeft,
CornerDownLeft,
} from "lucide-react";
import { CategoryBadge } from "@/components/BlindboxMyIdeas";
import Button from "@/components/Button";
import type { WeekendPlanData } from "@/types";
interface BlindboxPlanProps {
days: WeekendPlanData[];
onAccept: () => void;
onRegenerate: () => void;
onShare: () => void;
onBack: () => void;
accepted?: boolean;
regenerating?: boolean;
}
function guessCategory(activity: string): string | null {
const lower = activity.toLowerCase();
if (/吃|餐|饭|火锅|烧烤|面|菜|厨|食/.test(lower)) return "dining";
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`;
}
export default function BlindboxPlan({
days,
onAccept,
onRegenerate,
onShare,
onBack,
accepted,
regenerating,
}: 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;
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [dayIndex]);
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 pl-6"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.25 }}
>
<div className="absolute left-[9px] top-2 bottom-2 w-px bg-purple-500/20" />
{currentDay.items.map((item, i) => (
<motion.div
key={i}
className="relative mb-4 last:mb-0"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + i * 0.08 }}
>
<div className="absolute -left-6 top-3 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-xl bg-surface/80 p-3.5 ring-1 ring-border/80">
<div className="flex items-start gap-2">
<span className="mt-0.5 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-1.5 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-0.5 truncate text-[10px] text-dim">{item.address}</p>
)}
<div className="mt-2 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-1.5 text-[10px] leading-relaxed text-dim italic">
{item.reason}
</p>
)}
</div>
</div>
</div>
</motion.div>
))}
</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">
{/* 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>
</div>
);
}