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:
@@ -2,12 +2,64 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Package, Loader2, Pencil, Trash2, Check, X } from "lucide-react";
|
||||
import {
|
||||
Package,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
UtensilsCrossed,
|
||||
TreePine,
|
||||
Film,
|
||||
ShoppingBag,
|
||||
Dumbbell,
|
||||
Landmark,
|
||||
Coffee,
|
||||
} from "lucide-react";
|
||||
import type { IdeaCategory } from "@/types";
|
||||
|
||||
export interface MyIdea {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
category?: string | null;
|
||||
timeSlot?: string | null;
|
||||
estimatedMinutes?: number | null;
|
||||
outdoor?: boolean | null;
|
||||
searchQuery?: string | null;
|
||||
searchType?: string | null;
|
||||
}
|
||||
|
||||
const CATEGORY_CONFIG: Record<
|
||||
IdeaCategory,
|
||||
{ icon: typeof UtensilsCrossed; color: string; label: string }
|
||||
> = {
|
||||
dining: { icon: UtensilsCrossed, color: "text-orange-400", label: "美食" },
|
||||
outdoor: { icon: TreePine, color: "text-emerald-400", label: "户外" },
|
||||
entertainment: { icon: Film, color: "text-sky-400", label: "娱乐" },
|
||||
shopping: { icon: ShoppingBag, color: "text-pink-400", label: "购物" },
|
||||
sports: { icon: Dumbbell, color: "text-amber-400", label: "运动" },
|
||||
culture: { icon: Landmark, color: "text-violet-400", label: "文化" },
|
||||
relaxation: { icon: Coffee, color: "text-teal-400", label: "休闲" },
|
||||
};
|
||||
|
||||
function CategoryBadge({ category }: { category?: string | null }) {
|
||||
if (!category) return <span className="text-sm">💡</span>;
|
||||
const cfg = CATEGORY_CONFIG[category as IdeaCategory];
|
||||
if (!cfg) return <span className="text-sm">💡</span>;
|
||||
const Icon = cfg.icon;
|
||||
return <Icon size={14} className={`shrink-0 ${cfg.color}`} />;
|
||||
}
|
||||
|
||||
function DurationLabel({ minutes }: { minutes?: number | null }) {
|
||||
if (!minutes) return null;
|
||||
const display = minutes >= 60 ? `${(minutes / 60).toFixed(minutes % 60 === 0 ? 0 : 1)}h` : `${minutes}min`;
|
||||
return (
|
||||
<span className="shrink-0 rounded-md bg-elevated px-1.5 py-0.5 text-[10px] font-medium text-dim">
|
||||
~{display}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MyIdeaItem({
|
||||
@@ -66,8 +118,9 @@ function MyIdeaItem({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm">💡</span>
|
||||
<CategoryBadge category={idea.category} />
|
||||
<p className="min-w-0 flex-1 truncate text-sm text-secondary">{idea.content}</p>
|
||||
<DurationLabel minutes={idea.estimatedMinutes} />
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted transition-colors active:bg-elevated active:text-purple-400"
|
||||
@@ -119,3 +172,5 @@ export default function BlindboxMyIdeas({
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CATEGORY_CONFIG, CategoryBadge, DurationLabel };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import type { WeekendPlanData } from "@/types";
|
||||
|
||||
export interface PlanShareData {
|
||||
type: "plan";
|
||||
days: WeekendPlanData[];
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
export default function BlindboxPlanShareCard({
|
||||
data,
|
||||
cardRef,
|
||||
}: {
|
||||
data: PlanShareData;
|
||||
cardRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const { days, roomName } = data;
|
||||
const shareUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
style={{
|
||||
width: 340,
|
||||
padding: 1.5,
|
||||
borderRadius: 20,
|
||||
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 18.5,
|
||||
background: "#0a0810",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Decorative glows */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -30,
|
||||
right: -20,
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Brand header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>📋</span>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
NoWhatever
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
letterSpacing: "0.15em",
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
别说随便 · WEEKEND PLAN
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thin accent line */}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: "0 20px",
|
||||
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Each day */}
|
||||
{days.map((day, dayIdx) => (
|
||||
<div key={day.date}>
|
||||
{/* Room + date badge */}
|
||||
<div style={{ textAlign: "center", padding: "16px 20px 8px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.25em",
|
||||
color: "rgba(167,139,250,0.5)",
|
||||
}}
|
||||
>
|
||||
✦ {roomName} · {day.date} ✦
|
||||
</div>
|
||||
{day.summary && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 6,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{day.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline items */}
|
||||
<div style={{ padding: "12px 20px 20px" }}>
|
||||
{day.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
marginBottom: i < day.items.length - 1 ? 12 : 0,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 900,
|
||||
color: "#a78bfa",
|
||||
textAlign: "right",
|
||||
paddingTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.time}
|
||||
</div>
|
||||
|
||||
<div style={{ width: 16, flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#7c3aed",
|
||||
marginTop: 5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{i < day.items.length - 1 && (
|
||||
<div style={{ width: 1, flex: 1, background: "rgba(124,58,237,0.2)", marginTop: 4 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#ffffff" }}>
|
||||
{item.activity}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "rgba(167,139,250,0.5)", marginTop: 3 }}>
|
||||
📍 {item.poi}
|
||||
</div>
|
||||
{item.reason && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: "rgba(255,255,255,0.25)",
|
||||
marginTop: 3,
|
||||
fontStyle: "italic",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{item.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Separator between days */}
|
||||
{dayIdx < days.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: "0 20px",
|
||||
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.15), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Contract stamp */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "0 20px 16px",
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: "rgba(167,139,250,0.3)",
|
||||
}}
|
||||
>
|
||||
此契约一旦开启,绝不反悔
|
||||
</div>
|
||||
|
||||
{/* QR footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 5,
|
||||
borderRadius: 8,
|
||||
background: "#ffffff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={shareUrl}
|
||||
size={52}
|
||||
level="M"
|
||||
bgColor="#ffffff"
|
||||
fgColor="#0a0810"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "rgba(255,255,255,0.7)" }}>
|
||||
扫码一起「别说随便」
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.2)", marginTop: 3 }}>
|
||||
{shareUrl.replace(/^https?:\/\//, "")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,11 @@ import RestaurantShareCard, {
|
||||
import BlindboxShareCard, {
|
||||
type BlindboxShareData,
|
||||
} from "@/components/BlindboxShareCard";
|
||||
import BlindboxPlanShareCard, {
|
||||
type PlanShareData,
|
||||
} from "@/components/BlindboxPlanShareCard";
|
||||
|
||||
export type ShareCardData = RestaurantShareData | BlindboxShareData;
|
||||
export type ShareCardData = RestaurantShareData | BlindboxShareData | PlanShareData;
|
||||
|
||||
interface ShareCardModalProps {
|
||||
open: boolean;
|
||||
@@ -151,6 +154,8 @@ export default function ShareCardModal({
|
||||
cardRef={cardRef}
|
||||
imageDataUrl={imageDataUrl}
|
||||
/>
|
||||
) : data.type === "plan" ? (
|
||||
<BlindboxPlanShareCard data={data} cardRef={cardRef} />
|
||||
) : (
|
||||
<BlindboxShareCard data={data} cardRef={cardRef} />
|
||||
)}
|
||||
@@ -174,7 +179,7 @@ export default function ShareCardModal({
|
||||
onClick={handleShare}
|
||||
disabled={generating}
|
||||
className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-2xl text-sm font-bold text-white shadow-lg transition-colors disabled:opacity-50 ${
|
||||
data.type === "blindbox"
|
||||
data.type === "blindbox" || data.type === "plan"
|
||||
? "bg-purple-600 shadow-purple-900/30 active:bg-purple-500"
|
||||
: "bg-emerald-600 shadow-emerald-900/30 active:bg-emerald-500"
|
||||
}`}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Calendar, Clock, Sparkles, X } from "lucide-react";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
interface TimeConfig {
|
||||
date: string;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
const PRESETS: { label: string; value: TimeConfig }[] = [
|
||||
{ label: "周六全天", value: { date: "周六", startHour: 10, endHour: 21 } },
|
||||
{ label: "周日全天", value: { date: "周日", startHour: 10, endHour: 21 } },
|
||||
{ label: "整个周末", value: { date: "整个周末", startHour: 10, endHour: 21 } },
|
||||
];
|
||||
|
||||
const HOURS = Array.from({ length: 15 }, (_, i) => i + 7);
|
||||
|
||||
export default function WeekendTimeSelector({
|
||||
onConfirm,
|
||||
onClose,
|
||||
loading,
|
||||
}: {
|
||||
onConfirm: (config: TimeConfig) => void;
|
||||
onClose: () => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const [config, setConfig] = useState<TimeConfig>(PRESETS[0].value);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
className="w-full max-w-md rounded-t-3xl bg-surface px-6 pb-8 pt-4"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mx-auto mb-4 h-1 w-10 rounded-full bg-border" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={16} className="text-purple-400" />
|
||||
<h3 className="text-sm font-bold text-heading">选择可用时间</h3>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="关闭" className="text-muted active:text-foreground">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.value.date}
|
||||
onClick={() => setConfig({ ...preset.value })}
|
||||
className={`flex-1 rounded-xl py-2.5 text-xs font-bold transition-all ${
|
||||
config.date === preset.value.date
|
||||
? "bg-purple-600 text-white shadow-lg shadow-purple-900/30"
|
||||
: "bg-elevated text-secondary ring-1 ring-border"
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Clock size={14} className="shrink-0 text-muted" />
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<select
|
||||
value={config.startHour}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, startHour: Number(e.target.value) }))}
|
||||
className="h-9 flex-1 rounded-lg bg-elevated px-2 text-center text-sm font-semibold text-foreground ring-1 ring-border"
|
||||
>
|
||||
{HOURS.filter((h) => h < config.endHour).map((h) => (
|
||||
<option key={h} value={h}>{String(h).padStart(2, "0")}:00</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted">至</span>
|
||||
<select
|
||||
value={config.endHour}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, endHour: Number(e.target.value) }))}
|
||||
className="h-9 flex-1 rounded-lg bg-elevated px-2 text-center text-sm font-semibold text-foreground ring-1 ring-border"
|
||||
>
|
||||
{HOURS.filter((h) => h > config.startHour).map((h) => (
|
||||
<option key={h} value={h}>{String(h).padStart(2, "0")}:00</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => onConfirm(config)}
|
||||
variant="purple"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
icon={<Sparkles size={16} />}
|
||||
className="mt-6 w-full"
|
||||
>
|
||||
生成周末计划
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user