diff --git a/Jenkinsfile b/Jenkinsfile index 16cb8d6..2756cf9 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,8 +2,9 @@ pipeline { agent any environment { - APP_NAME = 'no-whatever' - AMAP_KEY = '7f6be40a6de3f7fbb7bc3f825b67573b' + APP_NAME = 'no-whatever' + AMAP_KEY = '7f6be40a6de3f7fbb7bc3f825b67573b' + DEEPSEEK_KEY = credentials('deepseek-api-key') } triggers { @@ -37,6 +38,7 @@ pipeline { -v /data/${APP_NAME}:/app/data \ -e DATABASE_URL=file:/app/data/prod.db \ -e AMAP_API_KEY=${AMAP_KEY} \ + -e DEEPSEEK_API_KEY=${DEEPSEEK_KEY} \ --restart unless-stopped \ ${APP_NAME}:latest """ diff --git a/package-lock.json b/package-lock.json index 664b278..91c7b03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "16.1.6", + "openai": "^6.25.0", "prisma": "^6.19.2", "qrcode.react": "^4.2.0", "react": "19.2.3", @@ -5613,6 +5614,27 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/openai": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.25.0.tgz", + "integrity": "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7101,7 +7123,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index 11715c8..c4d0916 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "16.1.6", + "openai": "^6.25.0", "prisma": "^6.19.2", "qrcode.react": "^4.2.0", "react": "19.2.3", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 58a6b1c..dbe18af 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { blindBoxMemberships BlindBoxMember[] submittedIdeas BlindBoxIdea[] @relation("IdeaSubmitter") drawnIdeas BlindBoxIdea[] @relation("IdeaDrawer") + weekendPlans WeekendPlan[] } model Decision { @@ -65,11 +66,15 @@ model BlindBoxRoom { code String @unique name String creatorId String + city String? + lat Float? + lng Float? createdAt DateTime @default(now()) creator User @relation("RoomCreator", fields: [creatorId], references: [id]) members BlindBoxMember[] ideas BlindBoxIdea[] + plans WeekendPlan[] } model BlindBoxMember { @@ -93,6 +98,13 @@ model BlindBoxIdea { drawnById String? createdAt DateTime @default(now()) + category String? + timeSlot String? + estimatedMinutes Int? + outdoor Boolean? + searchQuery String? + searchType String? + room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) user User @relation("IdeaSubmitter", fields: [userId], references: [id], onDelete: Cascade) drawnBy User? @relation("IdeaDrawer", fields: [drawnById], references: [id], onDelete: SetNull) @@ -100,3 +112,17 @@ model BlindBoxIdea { @@index([roomId, status]) @@index([userId]) } + +model WeekendPlan { + id String @id @default(cuid()) + roomId String + userId String + planData String + status String @default("active") + createdAt DateTime @default(now()) + + room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([roomId]) +} diff --git a/src/app/api/blindbox/plan/route.ts b/src/app/api/blindbox/plan/route.ts new file mode 100644 index 0000000..b0625fb --- /dev/null +++ b/src/app/api/blindbox/plan/route.ts @@ -0,0 +1,310 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireMembership } from "@/lib/blindbox"; +import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { requireAmapApiKey } from "@/lib/amap"; +import { generateSchedule, type ScheduleContext } from "@/lib/ai"; + +interface AvailableTime { + date: string; + startHour: number; + endHour: number; +} + +interface TaggedIdea { + id: string; + content: string; + category: string; + timeSlot: string; + estimatedMinutes: number; + searchQuery: string; + searchType: string; +} + +const SLOT_CATEGORY_MAP: Record = { + morning: ["outdoor", "sports", "culture"], + lunch: ["dining"], + afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture"], + dinner: ["dining"], + evening: ["entertainment", "relaxation"], +}; + +function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] { + const byCategory = new Map(); + for (const idea of ideas) { + const list = byCategory.get(idea.category) || []; + list.push(idea); + byCategory.set(idea.category, list); + } + + const slots: string[] = []; + if (availableHours >= 10) { + slots.push("morning", "lunch", "afternoon", "dinner", "evening"); + } else if (availableHours >= 7) { + slots.push("morning", "lunch", "afternoon", "evening"); + } else if (availableHours >= 5) { + slots.push("lunch", "afternoon", "evening"); + } else { + slots.push("afternoon", "evening"); + } + + const selected: TaggedIdea[] = []; + const usedIds = new Set(); + + for (const slot of slots) { + const preferredCategories = SLOT_CATEGORY_MAP[slot] || []; + + let picked: TaggedIdea | null = null; + for (const cat of preferredCategories) { + const pool = (byCategory.get(cat) || []).filter((i) => !usedIds.has(i.id)); + if (pool.length > 0) { + picked = pool[Math.floor(Math.random() * pool.length)]; + break; + } + } + + if (!picked) { + const remaining = ideas.filter((i) => !usedIds.has(i.id)); + if (remaining.length > 0) { + picked = remaining[Math.floor(Math.random() * remaining.length)]; + } + } + + if (picked) { + selected.push(picked); + usedIds.add(picked.id); + } + } + + return selected; +} + +async function searchPois( + query: string, + searchType: string, + anchorLat: number, + anchorLng: number, +): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> { + const apiKey = requireAmapApiKey(); + + if (searchType === "category") { + const url = new URL("https://restapi.amap.com/v5/place/around"); + url.searchParams.set("key", apiKey); + url.searchParams.set("location", `${anchorLng},${anchorLat}`); + url.searchParams.set("keywords", query); + url.searchParams.set("radius", "5000"); + url.searchParams.set("show_fields", "business"); + url.searchParams.set("page_size", "8"); + + const res = await fetch(url.toString()); + const data = await res.json(); + if (data.status !== "1" || !data.pois?.length) return []; + return mapPois(data.pois); + } + + // Text/brand search — bias results to the room's location + const url = new URL("https://restapi.amap.com/v5/place/text"); + url.searchParams.set("key", apiKey); + url.searchParams.set("keywords", query); + url.searchParams.set("location", `${anchorLng},${anchorLat}`); + url.searchParams.set("show_fields", "business"); + url.searchParams.set("page_size", "8"); + + const res = await fetch(url.toString()); + const data = await res.json(); + if (data.status !== "1" || !data.pois?.length) return []; + return mapPois(data.pois); +} + +function mapPois( + pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[], +) { + return pois + .filter((p) => p.location) + .map((p) => { + const [lng, lat] = (p.location ?? "0,0").split(",").map(Number); + const ratingStr = p.business?.rating; + return { + name: p.name, + address: p.address || "", + lat, + lng, + rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined, + }; + }); +} + +export const POST = apiHandler(async (req) => { + const { roomId, userId, availableTime } = await req.json(); + + requireUserId(userId); + if (!roomId) throw new ApiError("roomId 不能为空"); + + await requireMembership(roomId, userId); + + const at = availableTime as AvailableTime; + if ( + !at?.date || + typeof at.startHour !== "number" || + typeof at.endHour !== "number" || + at.endHour <= at.startHour + ) { + throw new ApiError("请选择有效的可用时间"); + } + + const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } }); + if (!room) throw new ApiError("房间不存在", 404); + if (!room.lat || !room.lng) { + throw new ApiError("请先设置房间位置", 400); + } + + const allIdeas = await prisma.blindBoxIdea.findMany({ + where: { roomId, status: "in_pool", category: { not: null } }, + select: { + id: true, + content: true, + category: true, + timeSlot: true, + estimatedMinutes: true, + searchQuery: true, + searchType: true, + }, + }); + + const taggedIdeas: TaggedIdea[] = allIdeas.filter( + (i): i is TaggedIdea => + !!i.category && !!i.timeSlot && !!i.searchQuery && !!i.searchType && + typeof i.estimatedMinutes === "number", + ); + + if (taggedIdeas.length < 2) { + throw new ApiError("盒子里至少需要 2 个已标记的想法才能生成计划", 400); + } + + // Split into day configs — "整个周末" generates two separate days + const dayConfigs: AvailableTime[] = + at.date === "整个周末" + ? [ + { date: "周六", startHour: at.startHour, endHour: at.endHour }, + { date: "周日", startHour: at.startHour, endHour: at.endHour }, + ] + : [at]; + + // Select ideas per day, avoiding duplicates across days when possible + const dayIdeas: TaggedIdea[][] = []; + const usedIds = new Set(); + for (const dayConfig of dayConfigs) { + const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id)); + const pool = remaining.length >= 2 ? remaining : taggedIdeas; + const selected = selectIdeasForSlots(pool, dayConfig.endHour - dayConfig.startHour); + for (const idea of selected) usedIds.add(idea.id); + dayIdeas.push(selected); + } + + const allSelected = dayIdeas.flat(); + if (allSelected.length === 0) { + throw new ApiError("无法从想法池中选出合适的活动", 400); + } + + // Deduplicate search queries across all days + const uniqueByQuery = new Map(); + for (const idea of allSelected) { + if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea); + } + + // Phase 1: search brand/place type queries in parallel + const brandPlaceQueries = [...uniqueByQuery.values()].filter((i) => i.searchType !== "category"); + + const searchResults = await Promise.all( + brandPlaceQueries.map(async (idea) => { + try { + const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat!, room.lng!); + return { query: idea.searchQuery, pois }; + } catch { + return { query: idea.searchQuery, pois: [] }; + } + }), + ); + + const candidates: ScheduleContext["candidates"] = {}; + for (const result of searchResults) { + candidates[result.query] = result.pois; + } + + // Phase 2: category-type queries anchored to centroid of found POIs + const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category"); + if (catQueries.length > 0) { + const allPois = Object.values(candidates).flat(); + let anchorLat = room.lat; + let anchorLng = room.lng; + if (allPois.length > 0) { + anchorLat = allPois.reduce((s, p) => s + p.lat, 0) / allPois.length; + anchorLng = allPois.reduce((s, p) => s + p.lng, 0) / allPois.length; + } + + const catResults = await Promise.all( + catQueries.map(async (idea) => { + try { + const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng); + return { query: idea.searchQuery, pois }; + } catch { + return { query: idea.searchQuery, pois: [] }; + } + }), + ); + + for (const result of catResults) { + candidates[result.query] = result.pois; + } + } + + // Generate schedule for each day (parallel AI calls) + const schedules = await Promise.all( + dayConfigs.map((dayConfig, idx) => { + const ideas = dayIdeas[idx]; + const ctx: ScheduleContext = { + ideas: ideas.map((i) => ({ + content: i.content, + category: i.category, + timeSlot: i.timeSlot, + estimatedMinutes: i.estimatedMinutes, + searchQuery: i.searchQuery, + searchType: i.searchType, + })), + candidates, + userLocation: { lat: room.lat!, lng: room.lng! }, + availableTime: dayConfig, + }; + return generateSchedule(ctx); + }), + ); + + const days = schedules + .map((schedule, idx) => + schedule + ? { date: dayConfigs[idx].date, items: schedule.items, summary: schedule.summary } + : null, + ) + .filter((d) => d !== null); + + if (days.length === 0) { + throw new ApiError("AI 规划失败,请稍后重试", 500); + } + + const plan = await prisma.weekendPlan.create({ + data: { + roomId, + userId, + planData: JSON.stringify({ + days, + selectedIdeaIds: allSelected.map((i) => i.id), + }), + }, + }); + + return NextResponse.json({ + id: plan.id, + days, + createdAt: plan.createdAt, + }); +}); diff --git a/src/app/api/blindbox/room/[code]/route.ts b/src/app/api/blindbox/room/[code]/route.ts index d67408d..62d1f30 100644 --- a/src/app/api/blindbox/room/[code]/route.ts +++ b/src/app/api/blindbox/room/[code]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { getRoomByCode } from "@/lib/blindbox"; +import { getRoomByCode, requireMembership } from "@/lib/blindbox"; import { apiHandler, ApiError, requireUserId } from "@/lib/api"; export const GET = apiHandler(async (_req, { params }) => { @@ -14,6 +14,9 @@ export const GET = apiHandler(async (_req, { params }) => { code: room.code, name: room.name, creatorId: room.creatorId, + city: room.city, + lat: room.lat, + lng: room.lng, poolCount: room._count.ideas, members: room.members.map((m) => ({ ...m.user, @@ -22,6 +25,44 @@ export const GET = apiHandler(async (_req, { params }) => { }); }); +export const PATCH = apiHandler(async (req, { params }) => { + const { code } = await params; + const { userId, city, lat, lng } = await req.json(); + + requireUserId(userId); + + const room = await prisma.blindBoxRoom.findUnique({ + where: { code: code.toUpperCase() }, + }); + if (!room) throw new ApiError("房间不存在", 404); + + await requireMembership(room.id, userId); + + const numLat = Number(lat); + const numLng = Number(lng); + if ( + !Number.isFinite(numLat) || !Number.isFinite(numLng) || + numLat < -90 || numLat > 90 || numLng < -180 || numLng > 180 + ) { + throw new ApiError("位置坐标无效"); + } + + const updated = await prisma.blindBoxRoom.update({ + where: { id: room.id }, + data: { + city: typeof city === "string" ? city.trim() : null, + lat: numLat, + lng: numLng, + }, + }); + + return NextResponse.json({ + city: updated.city, + lat: updated.lat, + lng: updated.lng, + }); +}); + export const DELETE = apiHandler(async (req, { params }) => { const { code } = await params; const { userId } = await req.json(); diff --git a/src/app/api/blindbox/route.ts b/src/app/api/blindbox/route.ts index 38a0912..91d4a42 100644 --- a/src/app/api/blindbox/route.ts +++ b/src/app/api/blindbox/route.ts @@ -3,6 +3,9 @@ import { prisma } from "@/lib/prisma"; import { requireMembership } from "@/lib/blindbox"; import { apiHandler, ApiError, requireUserId } from "@/lib/api"; import { validateIdeaContent, requireString } from "@/lib/validation"; +import { tagIdea } from "@/lib/ai"; + +const TAG_TIMEOUT_MS = 3000; export const POST = apiHandler(async (req) => { const { roomId, userId, content } = await req.json(); @@ -17,7 +20,29 @@ export const POST = apiHandler(async (req) => { data: { roomId, userId, content: trimmedContent }, }); - return NextResponse.json({ id: idea.id }, { status: 201 }); + const tags = await Promise.race([ + tagIdea(trimmedContent), + new Promise((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)), + ]); + + if (tags) { + await prisma.blindBoxIdea.update({ + where: { id: idea.id }, + data: { + category: tags.category, + timeSlot: tags.timeSlot, + estimatedMinutes: tags.estimatedMinutes, + outdoor: tags.outdoor, + searchQuery: tags.searchQuery, + searchType: tags.searchType, + }, + }); + } + + return NextResponse.json( + { id: idea.id, ...tags && { tags } }, + { status: 201 }, + ); }); export const GET = apiHandler(async (req) => { @@ -33,7 +58,17 @@ export const GET = apiHandler(async (req) => { prisma.blindBoxIdea.findMany({ where: { roomId, userId, status: "in_pool" }, orderBy: { createdAt: "desc" }, - select: { id: true, content: true, createdAt: true }, + select: { + id: true, + content: true, + createdAt: true, + category: true, + timeSlot: true, + estimatedMinutes: true, + outdoor: true, + searchQuery: true, + searchType: true, + }, }), prisma.blindBoxIdea.findMany({ where: { roomId, status: "drawn" }, @@ -62,7 +97,26 @@ export const PUT = apiHandler(async (req) => { if (count === 0) throw new ApiError("想法不存在、已被抽中或无权编辑", 404); - return NextResponse.json({ id: ideaId, content: trimmedContent }); + const tags = await Promise.race([ + tagIdea(trimmedContent), + new Promise((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)), + ]); + + if (tags) { + await prisma.blindBoxIdea.updateMany({ + where: { id: ideaId, userId, status: "in_pool" }, + data: { + category: tags.category, + timeSlot: tags.timeSlot, + estimatedMinutes: tags.estimatedMinutes, + outdoor: tags.outdoor, + searchQuery: tags.searchQuery, + searchType: tags.searchType, + }, + }); + } + + return NextResponse.json({ id: ideaId, content: trimmedContent, ...tags && { tags } }); }); export const DELETE = apiHandler(async (req) => { diff --git a/src/app/api/location/search/route.ts b/src/app/api/location/search/route.ts new file mode 100644 index 0000000..6462ab3 --- /dev/null +++ b/src/app/api/location/search/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { apiHandler, ApiError } from "@/lib/api"; +import { requireAmapApiKey } from "@/lib/amap"; + +interface AmapPoiV5 { + id: string; + name: string; + address?: string; + location?: string; + type?: string; + business?: { + rating?: string; + cost?: string; + tel?: string; + }; +} + +export const GET = apiHandler(async (req) => { + const keywords = req.nextUrl.searchParams.get("keywords")?.trim(); + if (!keywords) throw new ApiError("keywords 不能为空"); + + const city = req.nextUrl.searchParams.get("city")?.trim(); + const types = req.nextUrl.searchParams.get("types")?.trim(); + + const apiKey = requireAmapApiKey(); + + const url = new URL("https://restapi.amap.com/v5/place/text"); + url.searchParams.set("key", apiKey); + url.searchParams.set("keywords", keywords); + url.searchParams.set("show_fields", "business"); + url.searchParams.set("page_size", "10"); + + if (city) url.searchParams.set("region", city); + if (types) url.searchParams.set("types", types); + + let data; + try { + const res = await fetch(url.toString()); + data = await res.json(); + } catch { + throw new ApiError("位置服务暂时不可用,请稍后重试", 503); + } + + if (data.status !== "1" || !data.pois?.length) { + return NextResponse.json([]); + } + + const results = data.pois + .filter((poi: AmapPoiV5) => poi.location) + .map((poi: AmapPoiV5) => { + const [lng, lat] = (poi.location ?? "0,0").split(",").map(Number); + const ratingStr = poi.business?.rating; + const rating = ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null; + const costStr = poi.business?.cost; + const cost = costStr && costStr !== "[]" && costStr !== "0" ? Number(costStr) : null; + + return { + id: poi.id, + name: poi.name, + address: poi.address || "", + lat, + lng, + rating, + cost, + }; + }); + + return NextResponse.json(results); +}); diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index edbb3b4..740aba3 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -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([]); + const [planAccepted, setPlanAccepted] = useState(false); + const [generating, setGenerating] = useState(false); + const [showPlanShareCard, setShowPlanShareCard] = useState(false); const boxControls = useAnimation(); const inputRef = useRef(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((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 ( -
+
{/* Header */}
@@ -341,7 +440,21 @@ export default function BlindboxRoomPage() {

{room.name}

-

房间 {room.code}

+
+

房间 {room.code}

+ +
{/* Members */} @@ -424,44 +537,46 @@ export default function BlindboxRoomPage() { ) : ( <> - {/* Blind Box Visual */} -
- -
-
-
-
-
- - - - - ✨ - -
- + {/* Blind Box Visual — hidden during plan phases */} + {phase !== "planning" && phase !== "plan_reveal" && ( +
+ +
+
+
+
+
+ + + + + ✨ + +
+ - - 盒子里已有{" "} - {poolCount}{" "} - 个想法 - -
+ + 盒子里已有{" "} + {poolCount}{" "} + 个想法 + +
+ )} {/* Pool / Shaking / Reveal phases */} @@ -495,17 +610,35 @@ export default function BlindboxRoomPage() {
- -
- - 开启周末盲盒(绝不反悔) - +
+ +
+ + 抽一个 + + { + 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 }} + > + + 周末计划 + +
{error && ( )} + + {phase === "planning" && ( + + +
+ + +

+ AI 正在规划你的周末... +

+

搜索地点 · 优化路线 · 安排时间

+ + )} + + {phase === "plan_reveal" && planDays.length > 0 && ( + + { + setPlanAccepted(true); + fireConfetti(); + }} + onRegenerate={() => { + setPhase("time_select"); + }} + onShare={() => setShowPlanShareCard(true)} + onBack={() => { + setPhase("pool"); + setPlanDays([]); + setPlanAccepted(false); + }} + /> + + )} + + + {/* Time selector modal */} + + {phase === "time_select" && ( + setPhase("pool")} + loading={generating} + /> + )} {myIdeas.length > 0 && phase === "pool" && ( @@ -623,7 +819,7 @@ export default function BlindboxRoomPage() { /> )} - {phase !== "shaking" && ( + {phase !== "shaking" && phase !== "planning" && ( )} @@ -643,8 +839,20 @@ export default function BlindboxRoomPage() { /> )} - {/* Leave / Delete */} - {isMember && room && ( + {planDays.length > 0 && room && ( + setShowPlanShareCard(false)} + data={{ + type: "plan", + days: planDays, + roomName: room.name, + }} + /> + )} + + {/* Leave / Delete — hidden during plan view */} + {isMember && room && phase !== "plan_reveal" && phase !== "planning" && ( = { + 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 💡; + const cfg = CATEGORY_CONFIG[category as IdeaCategory]; + if (!cfg) return 💡; + const Icon = cfg.icon; + return ; +} + +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 ( + + ~{display} + + ); } function MyIdeaItem({ @@ -66,8 +118,9 @@ function MyIdeaItem({ ) : ( <> - 💡 +

{idea.content}

+
+ )} + + {currentDay.summary && ( + + {currentDay.summary} + + )} +
+ + {/* Scrollable timeline */} +
+ + +
+ + {currentDay.items.map((item, i) => ( + +
+
+
+ +
+
+ {item.time} +
+
+ +

{item.activity}

+
+
+ + {item.poi} +
+ {item.address && ( +

{item.address}

+ )} +
+ + + {formatDuration(item.duration)} + + {item.lat !== 0 && item.lng !== 0 && ( + + + 导航 + + )} +
+ {item.reason && ( +

+ {item.reason} +

+ )} +
+
+
+ + ))} + + + + {/* Back to pool — at end of scroll content */} +
+ +
+
+ + {/* Fixed bottom bar — actions + day navigation */} +
+ {/* Day navigation */} + {days.length > 1 && ( +
+ {hasPrev && ( + 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" + > + + {days[dayIndex - 1].date} + + )} + + {dayIndex + 1} / {days.length} + + {hasNext && ( + 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} + + + )} +
+ )} + + {/* Action buttons */} +
+ {accepted ? ( + + ) : ( + <> + + + + )} +
+
+
+ ); +} diff --git a/src/components/BlindboxPlanShareCard.tsx b/src/components/BlindboxPlanShareCard.tsx new file mode 100644 index 0000000..dc4ff8b --- /dev/null +++ b/src/components/BlindboxPlanShareCard.tsx @@ -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; +}) { + const { days, roomName } = data; + const shareUrl = + typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; + + return ( +
+
+ {/* Decorative glows */} +
+ + {/* Brand header */} +
+ 📋 +
+
+ NoWhatever +
+
+ 别说随便 · WEEKEND PLAN +
+
+
+ + {/* Thin accent line */} +
+ + {/* Each day */} + {days.map((day, dayIdx) => ( +
+ {/* Room + date badge */} +
+
+ ✦ {roomName} · {day.date} ✦ +
+ {day.summary && ( +
+ {day.summary} +
+ )} +
+ + {/* Timeline items */} +
+ {day.items.map((item, i) => ( +
+
+ {item.time} +
+ +
+
+ {i < day.items.length - 1 && ( +
+ )} +
+ +
+
+ {item.activity} +
+
+ 📍 {item.poi} +
+ {item.reason && ( +
+ {item.reason} +
+ )} +
+
+ ))} +
+ + {/* Separator between days */} + {dayIdx < days.length - 1 && ( +
+ )} +
+ ))} + + {/* Contract stamp */} +
+ 此契约一旦开启,绝不反悔 +
+ + {/* QR footer */} +
+
+ +
+
+
+ 扫码一起「别说随便」 +
+
+ {shareUrl.replace(/^https?:\/\//, "")} +
+
+
+
+
+ ); +} diff --git a/src/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx index db7e6ef..e87f7b3 100644 --- a/src/components/ShareCardModal.tsx +++ b/src/components/ShareCardModal.tsx @@ -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" ? ( + ) : ( )} @@ -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" }`} diff --git a/src/components/WeekendTimeSelector.tsx b/src/components/WeekendTimeSelector.tsx new file mode 100644 index 0000000..3c1f997 --- /dev/null +++ b/src/components/WeekendTimeSelector.tsx @@ -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(PRESETS[0].value); + + return ( + + e.stopPropagation()} + > +
+ +
+
+ +

选择可用时间

+
+ +
+ +
+ {PRESETS.map((preset) => ( + + ))} +
+ +
+ +
+ + + +
+
+ + + + + ); +} diff --git a/src/lib/ai.ts b/src/lib/ai.ts new file mode 100644 index 0000000..47f9db3 --- /dev/null +++ b/src/lib/ai.ts @@ -0,0 +1,172 @@ +import OpenAI from "openai"; +import type { IdeaTags, PlanItem } from "@/types"; + +function getClient() { + const apiKey = process.env.DEEPSEEK_API_KEY; + if (!apiKey) throw new Error("DEEPSEEK_API_KEY is not configured"); + return new OpenAI({ baseURL: "https://api.deepseek.com", apiKey }); +} + +const TAG_SYSTEM_PROMPT = `你是一个周末活动分析助手。用户会输入一条周末活动想法,你需要分析并返回结构化 JSON。 + +返回字段: +- category: 活动品类,必须是以下之一: + "dining"(餐饮美食)| "outdoor"(户外活动)| "entertainment"(娱乐休闲,如电影、KTV、密室)| "shopping"(购物逛街)| "sports"(运动健身)| "culture"(文化艺术,如博物馆、展览)| "relaxation"(放松休息,如SPA、咖啡、下午茶) +- timeSlot: 最适合的时间段,必须是以下之一: + "morning"(上午)| "afternoon"(下午)| "evening"(晚上)| "flexible"(任意时间都可以)| "all_day"(需要一整天) +- estimatedMinutes: 预估活动时长(整数,单位分钟) +- outdoor: 是否户外活动(布尔值) +- searchQuery: 在地图服务上搜索的关键词(品牌名、地名或品类名) +- searchType: 搜索策略,必须是以下之一: + "brand"(连锁品牌,有多个分店)| "place"(唯一地点,如某个公园)| "category"(模糊品类,搜附近匹配的) + +只返回 JSON,不要任何额外文字。`; + +const SCHEDULE_SYSTEM_PROMPT = `你是一个周末行程规划师。根据用户选定的活动和候选地点坐标,生成最优行程安排。 + +规划原则: +1. 选择地理位置相近的 POI,最小化总移动距离 +2. 尊重活动的时间偏好(公园上午、正餐在饭点、电影灵活) +3. 活动之间留出合理的交通时间(15-30分钟) +4. 如果有"category"类型的活动,选择离其他已确定地点最近的候选 + +返回 JSON 格式: +{ + "items": [ + { + "time": "10:00", + "activity": "原始活动描述", + "poi": "选定的具体 POI 名称", + "address": "详细地址", + "lat": 31.2, + "lng": 121.5, + "duration": 120, + "reason": "选择这个时间和地点的简短理由" + } + ], + "summary": "一句话总结这个行程的亮点" +} + +按时间顺序排列。只返回 JSON。`; + +export interface ScheduleContext { + ideas: { + content: string; + category: string; + timeSlot: string; + estimatedMinutes: number; + searchQuery: string; + searchType: string; + }[]; + candidates: Record< + string, + { name: string; address: string; lat: number; lng: number; rating?: number }[] + >; + userLocation: { lat: number; lng: number }; + availableTime: { date: string; startHour: number; endHour: number }; +} + +export async function tagIdea(content: string): Promise { + try { + const client = getClient(); + const response = await client.chat.completions.create({ + model: "deepseek-chat", + messages: [ + { role: "system", content: TAG_SYSTEM_PROMPT }, + { role: "user", content }, + ], + response_format: { type: "json_object" }, + max_tokens: 200, + temperature: 0.3, + }); + + const text = response.choices[0]?.message?.content; + if (!text) return null; + + const parsed = JSON.parse(text); + + const validCategories = ["dining", "outdoor", "entertainment", "shopping", "sports", "culture", "relaxation"]; + const validTimeSlots = ["morning", "afternoon", "evening", "flexible", "all_day"]; + const validSearchTypes = ["brand", "place", "category"]; + + if ( + !validCategories.includes(parsed.category) || + !validTimeSlots.includes(parsed.timeSlot) || + !validSearchTypes.includes(parsed.searchType) || + typeof parsed.estimatedMinutes !== "number" || + typeof parsed.outdoor !== "boolean" || + typeof parsed.searchQuery !== "string" + ) { + return null; + } + + return { + category: parsed.category, + timeSlot: parsed.timeSlot, + estimatedMinutes: parsed.estimatedMinutes, + outdoor: parsed.outdoor, + searchQuery: parsed.searchQuery, + searchType: parsed.searchType, + }; + } catch { + return null; + } +} + +export async function generateSchedule( + ctx: ScheduleContext, +): Promise<{ items: PlanItem[]; summary: string } | null> { + try { + const client = getClient(); + + const userPrompt = ` +可用时间:${ctx.availableTime.date},${ctx.availableTime.startHour}:00 - ${ctx.availableTime.endHour}:00 +用户出发位置:纬度 ${ctx.userLocation.lat},经度 ${ctx.userLocation.lng} + +活动列表: +${ctx.ideas.map((idea, i) => `${i + 1}. "${idea.content}"(品类:${idea.category},偏好时间:${idea.timeSlot},预估${idea.estimatedMinutes}分钟)`).join("\n")} + +各活动的候选地点: +${Object.entries(ctx.candidates) + .map( + ([query, pois]) => + `"${query}" 的候选:\n${pois.map((p) => ` - ${p.name} | ${p.address} | 坐标(${p.lat},${p.lng})${p.rating ? ` | 评分${p.rating}` : ""}`).join("\n")}`, + ) + .join("\n\n")} + +请为以上活动生成最优行程安排。`; + + const response = await client.chat.completions.create({ + model: "deepseek-chat", + messages: [ + { role: "system", content: SCHEDULE_SYSTEM_PROMPT }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + max_tokens: 1500, + temperature: 0.5, + }); + + const text = response.choices[0]?.message?.content; + if (!text) return null; + + const parsed = JSON.parse(text); + if (!Array.isArray(parsed.items) || parsed.items.length === 0) return null; + + return { + items: parsed.items.map((item: Record) => ({ + time: String(item.time ?? ""), + activity: String(item.activity ?? ""), + poi: String(item.poi ?? ""), + address: String(item.address ?? ""), + lat: Number(item.lat) || 0, + lng: Number(item.lng) || 0, + duration: Number(item.duration) || 60, + reason: String(item.reason ?? ""), + })), + summary: String(parsed.summary ?? ""), + }; + } catch { + return null; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 85c78c4..c9df25e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,3 +69,47 @@ export interface FavoriteRecord { restaurantData: Restaurant; createdAt: string; } + +export type IdeaCategory = + | "dining" + | "outdoor" + | "entertainment" + | "shopping" + | "sports" + | "culture" + | "relaxation"; + +export type IdeaTimeSlot = + | "morning" + | "afternoon" + | "evening" + | "flexible" + | "all_day"; + +export type IdeaSearchType = "brand" | "place" | "category"; + +export interface IdeaTags { + category: IdeaCategory; + timeSlot: IdeaTimeSlot; + estimatedMinutes: number; + outdoor: boolean; + searchQuery: string; + searchType: IdeaSearchType; +} + +export interface PlanItem { + time: string; + activity: string; + poi: string; + address: string; + lat: number; + lng: number; + duration: number; + reason: string; +} + +export interface WeekendPlanData { + date: string; + items: PlanItem[]; + summary: string; +}