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
+267 -59
View File
@@ -15,6 +15,9 @@ import {
Copy,
Trash2,
LogOut,
MapPin,
Calendar,
Sparkles,
} from "lucide-react";
import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId";
@@ -22,21 +25,26 @@ import ShareCardModal from "@/components/ShareCardModal";
import Button from "@/components/Button";
import BlindboxMyIdeas, { type MyIdea } from "@/components/BlindboxMyIdeas";
import BlindboxDrawnHistory, { type DrawnIdea } from "@/components/BlindboxDrawnHistory";
import WeekendTimeSelector from "@/components/WeekendTimeSelector";
import BlindboxPlan from "@/components/BlindboxPlan";
import { useToast } from "@/hooks/useToast";
import { useShare } from "@/hooks/useShare";
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types";
import type { UserProfile, WeekendPlanData } from "@/types";
interface RoomInfo {
id: string;
code: string;
name: string;
creatorId: string;
city: string | null;
lat: number | null;
lng: number | null;
poolCount: number;
members: { id: string; username: string; avatar: string }[];
}
type Phase = "pool" | "shaking" | "reveal";
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
export default function BlindboxRoomPage() {
const { code } = useParams<{ code: string }>();
@@ -62,6 +70,11 @@ export default function BlindboxRoomPage() {
const toast = useToast();
const [confirmLeave, setConfirmLeave] = useState(false);
const [leaving, setLeaving] = useState(false);
const [locating, setLocating] = useState(false);
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
const [planAccepted, setPlanAccepted] = useState(false);
const [generating, setGenerating] = useState(false);
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
const boxControls = useAnimation();
const inputRef = useRef<HTMLInputElement>(null);
@@ -151,6 +164,66 @@ export default function BlindboxRoomPage() {
finally { setJoiningRoom(false); }
};
const handleSetLocation = useCallback(async () => {
if (locating || !profile || !room) return;
setLocating(true);
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }),
);
const { latitude: lat, longitude: lng } = pos.coords;
const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
const regeo = regeoRes.ok ? await regeoRes.json() : {};
const cityName = regeo.name || "未知位置";
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 }),
});
if (!patchRes.ok) throw new Error("保存位置失败");
setRoom((prev) => prev ? { ...prev, city: cityName, lat, lng } : prev);
toast.show("位置已设置");
} catch {
toast.show("获取位置失败,请允许定位权限");
} finally {
setLocating(false);
}
}, [locating, profile, room, toast]);
const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => {
if (generating || !profile || !room) return;
setGenerating(true);
setPhase("planning");
setError("");
try {
const res = await fetch("/api/blindbox/plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: room.id,
userId: profile.id,
availableTime: timeConfig,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "生成失败");
}
const data = await res.json();
setPlanDays(data.days);
setPlanAccepted(false);
setPhase("plan_reveal");
fireConfetti();
} catch (e) {
setError(e instanceof Error ? e.message : "生成计划失败");
setPhase("pool");
} finally {
setGenerating(false);
}
}, [generating, profile, room]);
const handleSubmit = async () => {
const text = input.trim();
if (!text || submitting || !profile || !room) return;
@@ -166,10 +239,22 @@ export default function BlindboxRoomPage() {
const data = await res.json();
throw new Error(data.error || "提交失败");
}
const { id } = await res.json();
const data = await res.json();
setInput("");
setPoolCount((c) => c + 1);
setMyIdeas((prev) => [{ id, content: text, createdAt: new Date().toISOString() }, ...prev]);
setMyIdeas((prev) => [{
id: data.id,
content: text,
createdAt: new Date().toISOString(),
...data.tags && {
category: data.tags.category,
timeSlot: data.tags.timeSlot,
estimatedMinutes: data.tags.estimatedMinutes,
outdoor: data.tags.outdoor,
searchQuery: data.tags.searchQuery,
searchType: data.tags.searchType,
},
}, ...prev]);
setSubmitFlash(true);
timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600));
boxControls.start({
@@ -198,11 +283,23 @@ export default function BlindboxRoomPage() {
const data = await res.json();
throw new Error(data.error || "编辑失败");
}
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed } : i)));
const data = await res.json();
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? {
...i,
content: trimmed,
...data.tags && {
category: data.tags.category,
timeSlot: data.tags.timeSlot,
estimatedMinutes: data.tags.estimatedMinutes,
outdoor: data.tags.outdoor,
searchQuery: data.tags.searchQuery,
searchType: data.tags.searchType,
},
} : i)));
} catch (e) {
toast.show(e instanceof Error ? e.message : "编辑失败");
}
}, [profile]);
}, [profile, toast]);
const handleDeleteIdea = useCallback(async (ideaId: string) => {
if (!profile) return;
@@ -328,7 +425,9 @@ export default function BlindboxRoomPage() {
if (!room) return null;
return (
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
<div className={`relative flex h-dvh flex-col items-center bg-background px-5 py-6 scrollbar-none ${
phase === "plan_reveal" ? "overflow-hidden" : "overflow-y-auto"
}`}>
{/* Header */}
<div className="flex w-full max-w-sm items-center gap-3">
@@ -341,7 +440,21 @@ export default function BlindboxRoomPage() {
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-heading">{room.name}</p>
<p className="text-[10px] text-dim"> {room.code}</p>
<div className="mt-0.5 flex items-center gap-2">
<p className="text-[10px] text-dim"> {room.code}</p>
<button
onClick={handleSetLocation}
disabled={locating}
className="flex items-center gap-0.5 text-[10px] font-medium text-purple-400/70 transition-colors active:text-purple-400"
>
{locating ? (
<Loader2 size={9} className="animate-spin" />
) : (
<MapPin size={9} />
)}
{room.city || "设置位置"}
</button>
</div>
</div>
{/* Members */}
@@ -424,44 +537,46 @@ export default function BlindboxRoomPage() {
</motion.div>
) : (
<>
{/* Blind Box Visual */}
<div className="mt-8 flex flex-col items-center">
<motion.div
animate={boxControls}
className="relative flex h-36 w-36 items-center justify-center"
>
<div className="absolute inset-0 rounded-3xl bg-linear-to-br from-purple-600/20 to-indigo-600/20 blur-xl" />
<div className="relative flex h-32 w-32 flex-col items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-700/30">
<div className="absolute -top-2 left-1/2 h-5 w-[90%] -translate-x-1/2 rounded-t-xl bg-linear-to-r from-purple-700 to-indigo-700 shadow-md" />
<div className="absolute top-3 h-full w-3 bg-linear-to-b from-amber-400/60 to-amber-400/10" />
<div className="absolute top-6 h-3 w-full bg-linear-to-r from-amber-400/0 via-amber-400/60 to-amber-400/0" />
<motion.div
animate={submitFlash ? { scale: [1, 1.3, 1] } : {}}
transition={{ duration: 0.3 }}
>
<Package size={40} className="relative z-10 text-purple-300/60" strokeWidth={1.5} />
</motion.div>
<motion.div
className="absolute -right-2 -top-2 text-lg"
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
>
</motion.div>
</div>
</motion.div>
{/* Blind Box Visual — hidden during plan phases */}
{phase !== "planning" && phase !== "plan_reveal" && (
<div className="mt-8 flex flex-col items-center">
<motion.div
animate={boxControls}
className="relative flex h-36 w-36 items-center justify-center"
>
<div className="absolute inset-0 rounded-3xl bg-linear-to-br from-purple-600/20 to-indigo-600/20 blur-xl" />
<div className="relative flex h-32 w-32 flex-col items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-700/30">
<div className="absolute -top-2 left-1/2 h-5 w-[90%] -translate-x-1/2 rounded-t-xl bg-linear-to-r from-purple-700 to-indigo-700 shadow-md" />
<div className="absolute top-3 h-full w-3 bg-linear-to-b from-amber-400/60 to-amber-400/10" />
<div className="absolute top-6 h-3 w-full bg-linear-to-r from-amber-400/0 via-amber-400/60 to-amber-400/0" />
<motion.div
animate={submitFlash ? { scale: [1, 1.3, 1] } : {}}
transition={{ duration: 0.3 }}
>
<Package size={40} className="relative z-10 text-purple-300/60" strokeWidth={1.5} />
</motion.div>
<motion.div
className="absolute -right-2 -top-2 text-lg"
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
>
</motion.div>
</div>
</motion.div>
<motion.p
className="mt-4 text-sm font-semibold text-muted"
key={poolCount}
initial={{ scale: 1.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
{" "}
<span className="text-lg font-black text-purple-400">{poolCount}</span>{" "}
</motion.p>
</div>
<motion.p
className="mt-4 text-sm font-semibold text-muted"
key={poolCount}
initial={{ scale: 1.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
{" "}
<span className="text-lg font-black text-purple-400">{poolCount}</span>{" "}
</motion.p>
</div>
)}
{/* Pool / Shaking / Reveal phases */}
<AnimatePresence mode="wait">
@@ -495,17 +610,35 @@ export default function BlindboxRoomPage() {
</button>
</div>
<motion.button
onClick={handleDraw}
disabled={poolCount === 0}
className="relative flex h-14 w-full items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-base font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
<Flame size={20} />
</motion.button>
<div className="flex w-full gap-2">
<motion.button
onClick={handleDraw}
disabled={poolCount === 0}
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-sm font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
<Flame size={18} />
</motion.button>
<motion.button
onClick={() => {
if (!room?.city) {
toast.show("请先点击房间名下方设置位置");
return;
}
setPhase("time_select");
}}
disabled={poolCount < 2}
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-black text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50 disabled:opacity-40 disabled:shadow-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Calendar size={18} />
</motion.button>
</div>
{error && (
<motion.p
@@ -613,6 +746,69 @@ export default function BlindboxRoomPage() {
</div>
</motion.div>
)}
{phase === "planning" && (
<motion.div
key="planning"
className="mt-8 flex flex-col items-center gap-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="relative flex h-20 w-20 items-center justify-center"
animate={{ rotate: [0, 360] }}
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
>
<div className="absolute inset-0 rounded-full bg-purple-600/15 blur-lg" />
<Sparkles size={28} className="relative text-purple-400" />
</motion.div>
<p className="text-sm font-bold text-purple-300 animate-pulse">
AI ...
</p>
<p className="text-[11px] text-dim"> · 线 · </p>
</motion.div>
)}
{phase === "plan_reveal" && planDays.length > 0 && (
<motion.div
key="plan_reveal"
className="mt-4 flex min-h-0 flex-1 w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<BlindboxPlan
days={planDays}
accepted={planAccepted}
regenerating={generating}
onAccept={() => {
setPlanAccepted(true);
fireConfetti();
}}
onRegenerate={() => {
setPhase("time_select");
}}
onShare={() => setShowPlanShareCard(true)}
onBack={() => {
setPhase("pool");
setPlanDays([]);
setPlanAccepted(false);
}}
/>
</motion.div>
)}
</AnimatePresence>
{/* Time selector modal */}
<AnimatePresence>
{phase === "time_select" && (
<WeekendTimeSelector
onConfirm={handleGeneratePlan}
onClose={() => setPhase("pool")}
loading={generating}
/>
)}
</AnimatePresence>
{myIdeas.length > 0 && phase === "pool" && (
@@ -623,7 +819,7 @@ export default function BlindboxRoomPage() {
/>
)}
{phase !== "shaking" && (
{phase !== "shaking" && phase !== "planning" && (
<BlindboxDrawnHistory items={drawnHistory} />
)}
</>
@@ -643,8 +839,20 @@ export default function BlindboxRoomPage() {
/>
)}
{/* Leave / Delete */}
{isMember && room && (
{planDays.length > 0 && room && (
<ShareCardModal
open={showPlanShareCard}
onClose={() => setShowPlanShareCard(false)}
data={{
type: "plan",
days: planDays,
roomName: room.name,
}}
/>
)}
{/* Leave / Delete — hidden during plan view */}
{isMember && room && phase !== "plan_reveal" && phase !== "planning" && (
<motion.div
className="mt-12 w-full max-w-sm"
initial={{ opacity: 0 }}