feat(blindbox): AI 计划生成流式进度与渐进式文案
- 新增 runPlanGeneration 与 onProgress 回调 (blindboxPlanGen.ts) - 新增 POST /api/blindbox/plan/stream 推送 SSE 进度事件 - 前端优先走流式接口,实时展示「分析想法→搜索地点→规划路线→快好了」 - 流式失败时回退普通 POST,客户端轮播进度文案作为后备 - 规划阶段 UI 显示 planStatusMessage 替代静态文案
This commit is contained in:
@@ -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 | 邀请页信息补全 | 邀请转化率提升 | 低 |
|
||||||
|
| — | 网络断开提示 | 减少数据丢失投诉 | 中 |
|
||||||
|
| — | 滑卡触觉反馈 | 移动端操作满足感 | 低 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 核心原则:**先让更多人用起来,再让用起来的人留下来,最后让留下来的人带新人。**
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { requireMembership } from "@/lib/blindbox";
|
import { requireMembership } from "@/lib/blindbox";
|
||||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
import { requireAmapApiKey } from "@/lib/amap";
|
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
||||||
import { generateSchedule, type ScheduleContext } from "@/lib/ai";
|
|
||||||
|
|
||||||
interface AvailableTime {
|
interface AvailableTime {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -11,129 +9,6 @@ interface AvailableTime {
|
|||||||
endHour: number;
|
endHour: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaggedIdea {
|
|
||||||
id: string;
|
|
||||||
content: string;
|
|
||||||
category: string;
|
|
||||||
timeSlot: string;
|
|
||||||
estimatedMinutes: number;
|
|
||||||
searchQuery: string;
|
|
||||||
searchType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SLOT_CATEGORY_MAP: Record<string, string[]> = {
|
|
||||||
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<string, TaggedIdea[]>();
|
|
||||||
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<string>();
|
|
||||||
|
|
||||||
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) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { roomId, userId, availableTime } = await req.json();
|
const { roomId, userId, availableTime } = await req.json();
|
||||||
|
|
||||||
@@ -152,162 +27,12 @@ export const POST = apiHandler(async (req) => {
|
|||||||
throw new ApiError("请选择有效的可用时间");
|
throw new ApiError("请选择有效的可用时间");
|
||||||
}
|
}
|
||||||
|
|
||||||
const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } });
|
const result = await runPlanGeneration(roomId, userId!, at);
|
||||||
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<string>();
|
|
||||||
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<string, TaggedIdea>();
|
|
||||||
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),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: plan.id,
|
id: result.id,
|
||||||
days,
|
days: result.days,
|
||||||
createdAt: plan.createdAt,
|
createdAt: result.createdAt,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,7 +64,6 @@ function computeEndTime(planData: string, now: Date): Date | null {
|
|||||||
base.setHours(h, m, 0, 0);
|
base.setHours(h, m, 0, 0);
|
||||||
base.setMinutes(base.getMinutes() + (lastItem.duration || 60));
|
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()) {
|
if (base.getTime() < now.getTime()) {
|
||||||
base.setDate(base.getDate() + 7);
|
base.setDate(base.getDate() + 7);
|
||||||
}
|
}
|
||||||
@@ -355,6 +79,7 @@ export const PATCH = apiHandler(async (req) => {
|
|||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
if (!planId) throw new ApiError("planId 不能为空");
|
if (!planId) throw new ApiError("planId 不能为空");
|
||||||
|
|
||||||
|
const { prisma } = await import("@/lib/prisma");
|
||||||
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
|
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
|
||||||
if (!plan) throw new ApiError("计划不存在", 404);
|
if (!plan) throw new ApiError("计划不存在", 404);
|
||||||
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
|
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
|
||||||
@@ -389,6 +114,8 @@ export const GET = apiHandler(async (req) => {
|
|||||||
const userId = searchParams.get("userId");
|
const userId = searchParams.get("userId");
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
|
|
||||||
|
const { prisma } = await import("@/lib/prisma");
|
||||||
|
|
||||||
if (mode === "latest") {
|
if (mode === "latest") {
|
||||||
const roomId = searchParams.get("roomId");
|
const roomId = searchParams.get("roomId");
|
||||||
if (!roomId) throw new ApiError("roomId 不能为空");
|
if (!roomId) throw new ApiError("roomId 不能为空");
|
||||||
|
|||||||
@@ -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<Response> {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -51,6 +51,13 @@ interface RoomInfo {
|
|||||||
|
|
||||||
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
|
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
|
||||||
|
|
||||||
|
const PLAN_STATUS_STEPS = [
|
||||||
|
"正在分析你们的想法...",
|
||||||
|
"正在搜索地点...",
|
||||||
|
"正在规划路线...",
|
||||||
|
"快好了...",
|
||||||
|
];
|
||||||
|
|
||||||
const IDEA_INSPIRATIONS = [
|
const IDEA_INSPIRATIONS = [
|
||||||
"去城市最高楼看日落",
|
"去城市最高楼看日落",
|
||||||
"挑战一人做一道菜",
|
"挑战一人做一道菜",
|
||||||
@@ -109,6 +116,7 @@ export default function BlindboxRoomPage() {
|
|||||||
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
|
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
|
||||||
const [planAccepted, setPlanAccepted] = useState(false);
|
const [planAccepted, setPlanAccepted] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [planStatusMessage, setPlanStatusMessage] = useState("正在分析你们的想法...");
|
||||||
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
||||||
const [activeContract, setActiveContract] = useState<{
|
const [activeContract, setActiveContract] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -335,30 +343,81 @@ export default function BlindboxRoomPage() {
|
|||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setPhase("planning");
|
setPhase("planning");
|
||||||
setError("");
|
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 {
|
try {
|
||||||
const res = await fetch("/api/blindbox/plan", {
|
const res = await fetch("/api/blindbox/plan/stream", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
roomId: room.id,
|
|
||||||
userId: profile.id,
|
|
||||||
availableTime: timeConfig,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "生成失败");
|
throw new Error(data.error || "生成失败");
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const reader = res.body?.getReader();
|
||||||
setPlanId(data.id);
|
const decoder = new TextDecoder();
|
||||||
setPlanDays(data.days);
|
if (!reader) throw new Error("无法读取响应");
|
||||||
setPlanAccepted(false);
|
let buffer = "";
|
||||||
setPhase("plan_reveal");
|
while (true) {
|
||||||
fireConfetti();
|
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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "生成计划失败");
|
try {
|
||||||
setPhase("pool");
|
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 {
|
} finally {
|
||||||
|
clearInterval(fallbackTimer);
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
}, [generating, profile, room]);
|
}, [generating, profile, room]);
|
||||||
@@ -961,7 +1020,7 @@ export default function BlindboxRoomPage() {
|
|||||||
<Sparkles size={28} className="relative text-purple-400" />
|
<Sparkles size={28} className="relative text-purple-400" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<p className="text-sm font-bold text-purple-300 animate-pulse">
|
<p className="text-sm font-bold text-purple-300 animate-pulse">
|
||||||
AI 正在规划你的周末...
|
{planStatusMessage}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-dim">搜索地点 · 优化路线 · 安排时间</p>
|
<p className="text-[11px] text-dim">搜索地点 · 优化路线 · 安排时间</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -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<string, string[]> = {
|
||||||
|
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<string, TaggedIdea[]>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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<PlanGenResult> {
|
||||||
|
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<string>();
|
||||||
|
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<string, TaggedIdea>();
|
||||||
|
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<typeof 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 {
|
||||||
|
id: plan.id,
|
||||||
|
days,
|
||||||
|
createdAt: plan.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user