diff --git a/PRODUCT_OPTIMIZATION.md b/PRODUCT_OPTIMIZATION.md new file mode 100644 index 0000000..2b552e1 --- /dev/null +++ b/PRODUCT_OPTIMIZATION.md @@ -0,0 +1,194 @@ +# NoWhatever 产品优化建议 + +> 基于全量代码审查 + 现有 ROADMAP/BUGFIX 交叉分析,聚焦 ROADMAP 未覆盖的盲区。 +> 按产品影响力排序,非技术债务清单。 + +--- + +## 一、核心转化漏斗断裂(最高优先级) + +### 1. 新用户从"打开"到"第一次滑卡"路径太长 + +**现状**:首页 → 选模式 → 设置位置 → 选场景/口味/距离/价格 → 创建房间 → 等待加载 → 开始滑 +至少 **6 步 + 1 次定位授权**,每一步都是流失点。对选择困难症用户来说,让他们在设置页面做 4 个选择本身就是讽刺。 + +**建议**:增加「一键开始」快捷入口 —— 用默认定位 + 默认"吃饭"场景 + 3km 直接开房间,3 秒进入滑卡。高级设置保留给回头用户。 + +### 2. 切换场景清空已选偏好 + +**现状**:`handleSceneChange` 直接重置 cuisines 和 price,用户误触即丢失所有配置,无确认。 + +**建议**: +- 方案 A:缓存每个场景的偏好,切回时恢复 +- 方案 B:弹出"切换将清空当前设置,确定?"确认框 + +### 3. 定位失败引导含糊 + +**现状**:出现 4 种不同文案(定位中 / 失败 / 被拒绝 / 将使用当前定位),用户不知道该做什么。 + +**建议**:统一为一个明确的 CTA —— 要么"允许定位"按钮,要么"手动搜索位置"输入框,不要同时展示多个状态。 + +--- + +## 二、匹配成功后的闭环缺失 + +### 4. 缺少"执行"追踪环节 + +**现状**:匹配到餐厅后,除了导航和电话,没有后续追踪。没有"去了吗?好吃吗?"的闭环。 + +**建议**:匹配后 2 小时推浏览器通知:"去 XXX 了吗?打个分吧"。分数数据反哺未来推荐质量,同时形成用户习惯。 + +### 5. 收藏按钮隐藏在登录之后 + +**现状**:未登录用户在 MatchResult 上看不到收藏按钮,但这恰恰是用户最有收藏冲动的时刻。 + +**建议**:始终展示心形按钮,点击时触发注册引导("注册即可保存这家店")。转化率优于当前的被动注册引导卡片。 + +### 6. "Top N 决赛"理解成本高 + +**现状**:非全员一致时"缩小范围"按钮直接出现,没有解释这是什么、为什么需要。 + +**建议**:在按钮上方加引导语:"还有 X 家不相上下,再投一轮?"(ROADMAP 已提及但未实现) + +--- + +## 三、盲盒模式参与度瓶颈 + +### 7. 登录门槛太靠前 + +**现状**:打开盲盒页面第一件事就是弹登录框,用户连盲盒是什么都没看到就被要求注册。 + +**建议**:先展示 demo 房间 / 动画演示 / 已有房间的公开预览,让用户理解价值后再引导登录。 + +### 8. 提交想法缺乏引导和灵感 + +**现状**:输入框只有 placeholder "写下你的想法...",用户面对空白框不知道写什么。 + +**建议**:展示 3-5 个随机示例("去城市最高楼看日落"、"挑战一人做一道菜"、"找一家从没去过的店"),点击可直接填入,降低创作门槛。 + +### 9. AI 计划生成无进度反馈 ✅ + +**现状**:调用 DeepSeek 生成周末计划可能需要 5-10 秒,只有 phase 变化,用户以为卡死了。 + +**建议**:加逐步动画:"正在分析你们的想法..." → "正在规划路线..." → "快好了...",降低等待焦虑。 + +**已实现**: +- 新增流式接口 `POST /api/blindbox/plan/stream`,按步骤推送 SSE 进度(分析想法 → 搜索地点 → 规划路线 → 规划周六/周日 → 快好了)。 +- 前端优先走流式接口,实时展示服务端进度文案;流式失败时自动回退到普通 `POST /api/blindbox/plan`,并用客户端轮播文案(约 2.8 秒一档)作为后备。 +- 计划生成逻辑抽到 `src/lib/blindboxPlanGen.ts`,支持 `onProgress` 回调,供流式与普通接口共用。 + +### 10. 契约完成率没有激励闭环 + +**现状**:用户看到完成率数字,但没有任何奖励或惩罚机制。 + +**建议**: +- 连续完成 3 个契约 → 解锁特殊标签 / 头像框 +- 连续放鸽子 → 对方可以"惩罚抽签"(指定必须做某个想法) + +--- + +## 四、单人使用体验 + +### 11. 单人创建房间后陷入永久等待 + +**现状**:`userCount === 1` 时仍然进入"等待其他人完成选择"的 spinner。大量用户其实是单人使用(自己选不了吃什么)。 + +**建议**:单人模式直接出结果,文案改为"帮你选好了!"而非"全员一致"。这解锁了最大的用户群体。 + +--- + +## 五、留存与回访机制空白 + +### 12. 没有任何回访触发点 + +**现状**:用户用完一次后没有理由回来。没有推送、没有提醒、没有"每周报告"。 + +**建议**: +- 短期:profile 页加"下次吃什么?"快捷入口 + "上次你们选了 XXX,要不要换一家?" +- 中期:接浏览器 Notification API,工作日下午 5 点推"今晚吃什么?" + +### 13. 成就系统只有数字没有情感 + +**现状**:成就页是统计数据的平铺,没有里程碑、勋章、解锁动画。 + +**建议**:加入称号体系("选择恐惧症治愈者 Lv.3"、"周末冒险家"),每次解锁新称号播放庆祝动画。数字驱动不了留存,情感才可以。 + +### 14. 缺少"历史今天"触发 + +**现状**:decision 表有 `createdAt` 字段,但从未用于回忆功能。 + +**建议**:"一年前的今天,你和 TA 在 XXX 吃了火锅" —— 极强的情感连接 + 回访理由。在首页或成就页展示。 + +--- + +## 六、社交裂变短板 + +### 15. 分享卡片缺少行动号召 + +**现状**:生成的分享图有餐厅信息 + 二维码,但没有一句吸引新用户的 hook。 + +**建议**:卡片底部加一行:"选择困难?扫码让 TA 帮你决定 →" + +### 16. 邀请页信息不足 + +**现状**:被邀请者只看到房间 ID 和人数,不知道在选什么、在哪里、选了多久。 + +**建议**:展示房间场景(吃饭/喝酒)、位置、已滑卡进度,降低加入决策成本。 + +--- + +## 七、技术层面影响产品体验的问题 + +| 问题 | 产品影响 | 建议 | +|------|---------|------| +| 网络断开无任何提示 | 用户滑卡后不知道是否提交成功 | 加"网络已断开"toast + 自动重连机制 | +| 滑卡没有触觉反馈 | 移动端操作感弱,不确定是否成功 | 调用 `navigator.vibrate()` | +| 房间 24h 过期且未登录用户无记录 | 第二天想看"昨天选了什么"看不到 | 引导注册,或用 localStorage 缓存最近一次结果 | +| 图片加载失败静默 | 空白卡片严重影响决策质量 | 加 fallback 占位图 + 重试按钮 | +| 餐厅数量不可预期 | 用户不知道有多少张卡要滑 | 搜索后展示"找到 X 家餐厅",减少焦虑 | + +--- + +## 优先级排序 + +### 第一梯队:解决"用了但没用起来" + +| # | 改动 | 预期效果 | 复杂度 | +|---|------|---------|--------| +| 1 | "一键开始"快速通道 | 新用户转化率翻倍 | 低 | +| 11 | 单人模式自动出结果 | 解锁最大用户群体 | 低 | +| 7 | 盲盒登录后置 | 盲盒模式使用率提升 | 中 | + +### 第二梯队:补全核心体验 + +| # | 改动 | 预期效果 | 复杂度 | +|---|------|---------|--------| +| 5 | 收藏按钮始终可见 | 注册转化率提升 | 低 | +| 6 | Top N 决赛引导语 | 减少二轮投票的困惑 | 低 | +| 8 | 想法灵感提示 | 想法提交量提升 | 低 | +| 9 | AI 生成进度动画 | 减少等待流失 | 低 | +| 3 | 定位引导优化 | 减少首次使用失败率 | 低 | + +### 第三梯队:留存与增长 + +| # | 改动 | 预期效果 | 复杂度 | +|---|------|---------|--------| +| 4 | 执行追踪 + 打分 | 形成使用习惯 | 中 | +| 12 | 回访通知 | 次日/次周留存提升 | 中 | +| 13 | 情感化成就系统 | 长期留存 | 中 | +| 15 | 分享卡片行动号召 | 自然裂变提升 | 低 | +| 14 | 历史今天 | 情感连接 + 回访 | 低 | + +### 第四梯队:体验打磨 + +| # | 改动 | 预期效果 | 复杂度 | +|---|------|---------|--------| +| 2 | 场景切换保留偏好 | 减少操作挫败感 | 低 | +| 10 | 契约激励闭环 | 盲盒活跃度 | 中 | +| 16 | 邀请页信息补全 | 邀请转化率提升 | 低 | +| — | 网络断开提示 | 减少数据丢失投诉 | 中 | +| — | 滑卡触觉反馈 | 移动端操作满足感 | 低 | + +--- + +> 核心原则:**先让更多人用起来,再让用起来的人留下来,最后让留下来的人带新人。** diff --git a/src/app/api/blindbox/plan/route.ts b/src/app/api/blindbox/plan/route.ts index 924b784..061875d 100644 --- a/src/app/api/blindbox/plan/route.ts +++ b/src/app/api/blindbox/plan/route.ts @@ -1,9 +1,7 @@ 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"; +import { runPlanGeneration } from "@/lib/blindboxPlanGen"; interface AvailableTime { date: string; @@ -11,129 +9,6 @@ interface AvailableTime { 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(); @@ -152,162 +27,12 @@ export const POST = apiHandler(async (req) => { 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 — skip extra days when ideas run out - const dayIdeas: TaggedIdea[][] = []; - const usedIds = new Set(); - for (const dayConfig of dayConfigs) { - const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id)); - if (remaining.length < 2) break; - const selected = selectIdeasForSlots(remaining, dayConfig.endHour - dayConfig.startHour); - for (const idea of selected) usedIds.add(idea.id); - dayIdeas.push(selected); - } - // Trim to actual days generated (may be fewer than requested for "整个周末") - const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length); - - 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( - actualDayConfigs.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: actualDayConfigs[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), - }), - }, - }); + const result = await runPlanGeneration(roomId, userId!, at); return NextResponse.json({ - id: plan.id, - days, - createdAt: plan.createdAt, + id: result.id, + days: result.days, + createdAt: result.createdAt, }); }); @@ -339,7 +64,6 @@ function computeEndTime(planData: string, now: Date): Date | null { base.setHours(h, m, 0, 0); base.setMinutes(base.getMinutes() + (lastItem.duration || 60)); - // If computed end time is in the past, it's for next week if (base.getTime() < now.getTime()) { base.setDate(base.getDate() + 7); } @@ -355,6 +79,7 @@ export const PATCH = apiHandler(async (req) => { requireUserId(userId); if (!planId) throw new ApiError("planId 不能为空"); + const { prisma } = await import("@/lib/prisma"); const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } }); if (!plan) throw new ApiError("计划不存在", 404); if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403); @@ -389,6 +114,8 @@ export const GET = apiHandler(async (req) => { const userId = searchParams.get("userId"); requireUserId(userId); + const { prisma } = await import("@/lib/prisma"); + if (mode === "latest") { const roomId = searchParams.get("roomId"); if (!roomId) throw new ApiError("roomId 不能为空"); diff --git a/src/app/api/blindbox/plan/stream/route.ts b/src/app/api/blindbox/plan/stream/route.ts new file mode 100644 index 0000000..93d2a92 --- /dev/null +++ b/src/app/api/blindbox/plan/stream/route.ts @@ -0,0 +1,78 @@ +import { requireMembership } from "@/lib/blindbox"; +import { requireUserId } from "@/lib/api"; +import { runPlanGeneration } from "@/lib/blindboxPlanGen"; + +function encodeSSE(event: string, data: string): string { + return `event: ${event}\ndata: ${data}\n\n`; +} + +export async function POST(req: Request): Promise { + let roomId: string; + let userId: string; + let availableTime: { date: string; startHour: number; endHour: number }; + + try { + const body = await req.json(); + roomId = body.roomId; + userId = body.userId; + availableTime = body.availableTime; + + requireUserId(userId); + if (!roomId) { + return new Response( + JSON.stringify({ error: "roomId 不能为空" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + await requireMembership(roomId, userId); + + const at = availableTime; + if ( + !at?.date || + typeof at.startHour !== "number" || + typeof at.endHour !== "number" || + at.endHour <= at.startHour + ) { + return new Response( + JSON.stringify({ error: "请选择有效的可用时间" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + } catch (e) { + const message = e instanceof Error ? e.message : "请求参数错误"; + return new Response(JSON.stringify({ error: message }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + const push = (event: string, data: string) => { + controller.enqueue(encoder.encode(encodeSSE(event, data))); + }; + + try { + const result = await runPlanGeneration(roomId!, userId!, availableTime!, (message) => { + push("status", message); + }); + push("plan", JSON.stringify({ id: result.id, days: result.days, createdAt: result.createdAt })); + } catch (e) { + const message = e instanceof Error ? e.message : "生成计划失败"; + push("error", message); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index fd78c23..40c6b08 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -51,6 +51,13 @@ interface RoomInfo { type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal"; +const PLAN_STATUS_STEPS = [ + "正在分析你们的想法...", + "正在搜索地点...", + "正在规划路线...", + "快好了...", +]; + const IDEA_INSPIRATIONS = [ "去城市最高楼看日落", "挑战一人做一道菜", @@ -109,6 +116,7 @@ export default function BlindboxRoomPage() { const [planDays, setPlanDays] = useState([]); const [planAccepted, setPlanAccepted] = useState(false); const [generating, setGenerating] = useState(false); + const [planStatusMessage, setPlanStatusMessage] = useState("正在分析你们的想法..."); const [showPlanShareCard, setShowPlanShareCard] = useState(false); const [activeContract, setActiveContract] = useState<{ id: string; @@ -335,30 +343,81 @@ export default function BlindboxRoomPage() { setGenerating(true); setPhase("planning"); setError(""); + setPlanStatusMessage(PLAN_STATUS_STEPS[0]); + const payload = { + roomId: room.id, + userId: profile.id, + availableTime: timeConfig, + }; + const stepRef = { current: 0 }; + const fallbackTimer = setInterval(() => { + stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length; + setPlanStatusMessage(PLAN_STATUS_STEPS[stepRef.current]); + }, 2800); try { - const res = await fetch("/api/blindbox/plan", { + const res = await fetch("/api/blindbox/plan/stream", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - roomId: room.id, - userId: profile.id, - availableTime: timeConfig, - }), + body: JSON.stringify(payload), }); if (!res.ok) { - const data = await res.json(); + const data = await res.json().catch(() => ({})); throw new Error(data.error || "生成失败"); } - const data = await res.json(); - setPlanId(data.id); - setPlanDays(data.days); - setPlanAccepted(false); - setPhase("plan_reveal"); - fireConfetti(); + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + if (!reader) throw new Error("无法读取响应"); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() ?? ""; + for (const block of blocks) { + let eventType = ""; + let data = ""; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) eventType = line.slice(6).trim(); + else if (line.startsWith("data:")) data = line.slice(5).trim(); + } + if (eventType === "status") setPlanStatusMessage(data); + else if (eventType === "plan") { + const parsed = JSON.parse(data); + setPlanId(parsed.id); + setPlanDays(parsed.days); + setPlanAccepted(false); + setPhase("plan_reveal"); + fireConfetti(); + } else if (eventType === "error") { + setError(data || "生成计划失败"); + setPhase("pool"); + } + } + } } catch (e) { - setError(e instanceof Error ? e.message : "生成计划失败"); - setPhase("pool"); + try { + const res = await fetch("/api/blindbox/plan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "生成失败"); + } + const data = await res.json(); + setPlanId(data.id); + setPlanDays(data.days); + setPlanAccepted(false); + setPhase("plan_reveal"); + fireConfetti(); + } catch (fallbackErr) { + setError(fallbackErr instanceof Error ? fallbackErr.message : "生成计划失败"); + setPhase("pool"); + } } finally { + clearInterval(fallbackTimer); setGenerating(false); } }, [generating, profile, room]); @@ -961,7 +1020,7 @@ export default function BlindboxRoomPage() {

- AI 正在规划你的周末... + {planStatusMessage}

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

diff --git a/src/lib/blindboxPlanGen.ts b/src/lib/blindboxPlanGen.ts new file mode 100644 index 0000000..b38d022 --- /dev/null +++ b/src/lib/blindboxPlanGen.ts @@ -0,0 +1,318 @@ +/** + * Shared plan generation logic for blindbox weekend plans. + * Supports optional progress callback for streaming UX. + */ +import { prisma } from "@/lib/prisma"; +import { requireAmapApiKey } from "@/lib/amap"; +import { generateSchedule, type ScheduleContext } from "@/lib/ai"; +import { ApiError } from "@/lib/api"; + +export interface PlanGenAvailableTime { + 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); + } + + 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 PLAN_PROGRESS_MESSAGES = { + analyzing: "正在分析你们的想法...", + searching: "正在搜索地点...", + planning: "正在规划路线...", + planningDay: (day: string) => `正在规划${day}...`, + almostDone: "快好了...", +} as const; + +export interface PlanGenResult { + id: string; + days: { date: string; items: unknown[]; summary: string }[]; + createdAt: string; +} + +export async function runPlanGeneration( + roomId: string, + userId: string, + availableTime: PlanGenAvailableTime, + onProgress?: (message: string) => void, +): Promise { + const at = availableTime; + + const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } }); + if (!room) throw new ApiError("房间不存在", 404); + if (!room.lat || !room.lng) { + throw new ApiError("请先设置房间位置", 400); + } + + onProgress?.(PLAN_PROGRESS_MESSAGES.analyzing); + + 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); + } + + const dayConfigs: PlanGenAvailableTime[] = + at.date === "整个周末" + ? [ + { date: "周六", startHour: at.startHour, endHour: at.endHour }, + { date: "周日", startHour: at.startHour, endHour: at.endHour }, + ] + : [at]; + + const dayIdeas: TaggedIdea[][] = []; + const usedIds = new Set(); + for (const dayConfig of dayConfigs) { + const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id)); + if (remaining.length < 2) break; + const selected = selectIdeasForSlots(remaining, dayConfig.endHour - dayConfig.startHour); + for (const idea of selected) usedIds.add(idea.id); + dayIdeas.push(selected); + } + const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length); + + const allSelected = dayIdeas.flat(); + if (allSelected.length === 0) { + throw new ApiError("无法从想法池中选出合适的活动", 400); + } + + const uniqueByQuery = new Map(); + for (const idea of allSelected) { + if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea); + } + + onProgress?.(PLAN_PROGRESS_MESSAGES.searching); + + 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; + } + + 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; + } + } + + onProgress?.(PLAN_PROGRESS_MESSAGES.planning); + + const schedules = await Promise.all( + actualDayConfigs.map((dayConfig, idx) => { + onProgress?.(PLAN_PROGRESS_MESSAGES.planningDay(dayConfig.date)); + 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); + }), + ); + + onProgress?.(PLAN_PROGRESS_MESSAGES.almostDone); + + const days = schedules + .map((schedule, idx) => + schedule + ? { + date: actualDayConfigs[idx].date, + items: schedule.items, + summary: schedule.summary, + } + : null, + ) + .filter((d): d is NonNullable => 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 { + id: plan.id, + days, + createdAt: plan.createdAt.toISOString(), + }; +}