feat: AI 周末行程规划 — DeepSeek 智能排期 + 高德 POI + 多日翻页
- 接入 DeepSeek API,提交想法时自动 AI 打标(品类/时段/时长/搜索策略) - 新增行程规划 API:智能选取想法 → 高德 POI 搜索 → AI 生成最优行程 - 支持多日计划("整个周末"拆分周六/周日,并行 AI 调用) - 行程展示逐日翻页,时间线可滚动,操作按钮固定底部 - 分享卡适配多日格式,支持图片保存与原生分享 - Prisma schema 新增 WeekendPlan 模型及 BlindBoxIdea AI 标签字段 - Jenkinsfile 集成 DEEPSEEK_API_KEY 环境变量
This commit is contained in:
Vendored
+4
-2
@@ -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
|
||||
"""
|
||||
|
||||
Generated
+23
-1
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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<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) => {
|
||||
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<string>();
|
||||
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<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(
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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<null>((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<null>((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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
Copy,
|
||||
Trash2,
|
||||
LogOut,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import confetti from "canvas-confetti";
|
||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||
@@ -22,21 +25,26 @@ import ShareCardModal from "@/components/ShareCardModal";
|
||||
import Button from "@/components/Button";
|
||||
import BlindboxMyIdeas, { type MyIdea } from "@/components/BlindboxMyIdeas";
|
||||
import BlindboxDrawnHistory, { type DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
||||
import WeekendTimeSelector from "@/components/WeekendTimeSelector";
|
||||
import BlindboxPlan from "@/components/BlindboxPlan";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useShare } from "@/hooks/useShare";
|
||||
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
|
||||
import type { UserProfile } from "@/types";
|
||||
import type { UserProfile, WeekendPlanData } from "@/types";
|
||||
|
||||
interface RoomInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
creatorId: string;
|
||||
city: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
poolCount: number;
|
||||
members: { id: string; username: string; avatar: string }[];
|
||||
}
|
||||
|
||||
type Phase = "pool" | "shaking" | "reveal";
|
||||
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
|
||||
|
||||
export default function BlindboxRoomPage() {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
@@ -62,6 +70,11 @@ export default function BlindboxRoomPage() {
|
||||
const toast = useToast();
|
||||
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
|
||||
const [planAccepted, setPlanAccepted] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
||||
|
||||
const boxControls = useAnimation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -151,6 +164,66 @@ export default function BlindboxRoomPage() {
|
||||
finally { setJoiningRoom(false); }
|
||||
};
|
||||
|
||||
const handleSetLocation = useCallback(async () => {
|
||||
if (locating || !profile || !room) return;
|
||||
setLocating(true);
|
||||
try {
|
||||
const pos = await new Promise<GeolocationPosition>((resolve, reject) =>
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }),
|
||||
);
|
||||
const { latitude: lat, longitude: lng } = pos.coords;
|
||||
const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
|
||||
const regeo = regeoRes.ok ? await regeoRes.json() : {};
|
||||
const cityName = regeo.name || "未知位置";
|
||||
|
||||
const patchRes = await fetch(`/api/blindbox/room/${room.code}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, city: cityName, lat, lng }),
|
||||
});
|
||||
if (!patchRes.ok) throw new Error("保存位置失败");
|
||||
|
||||
setRoom((prev) => prev ? { ...prev, city: cityName, lat, lng } : prev);
|
||||
toast.show("位置已设置");
|
||||
} catch {
|
||||
toast.show("获取位置失败,请允许定位权限");
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}, [locating, profile, room, toast]);
|
||||
|
||||
const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => {
|
||||
if (generating || !profile || !room) return;
|
||||
setGenerating(true);
|
||||
setPhase("planning");
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
roomId: room.id,
|
||||
userId: profile.id,
|
||||
availableTime: timeConfig,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "生成失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
setPlanDays(data.days);
|
||||
setPlanAccepted(false);
|
||||
setPhase("plan_reveal");
|
||||
fireConfetti();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "生成计划失败");
|
||||
setPhase("pool");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [generating, profile, room]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || submitting || !profile || !room) return;
|
||||
@@ -166,10 +239,22 @@ export default function BlindboxRoomPage() {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "提交失败");
|
||||
}
|
||||
const { id } = await res.json();
|
||||
const data = await res.json();
|
||||
setInput("");
|
||||
setPoolCount((c) => c + 1);
|
||||
setMyIdeas((prev) => [{ id, content: text, createdAt: new Date().toISOString() }, ...prev]);
|
||||
setMyIdeas((prev) => [{
|
||||
id: data.id,
|
||||
content: text,
|
||||
createdAt: new Date().toISOString(),
|
||||
...data.tags && {
|
||||
category: data.tags.category,
|
||||
timeSlot: data.tags.timeSlot,
|
||||
estimatedMinutes: data.tags.estimatedMinutes,
|
||||
outdoor: data.tags.outdoor,
|
||||
searchQuery: data.tags.searchQuery,
|
||||
searchType: data.tags.searchType,
|
||||
},
|
||||
}, ...prev]);
|
||||
setSubmitFlash(true);
|
||||
timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600));
|
||||
boxControls.start({
|
||||
@@ -198,11 +283,23 @@ export default function BlindboxRoomPage() {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "编辑失败");
|
||||
}
|
||||
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed } : i)));
|
||||
const data = await res.json();
|
||||
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? {
|
||||
...i,
|
||||
content: trimmed,
|
||||
...data.tags && {
|
||||
category: data.tags.category,
|
||||
timeSlot: data.tags.timeSlot,
|
||||
estimatedMinutes: data.tags.estimatedMinutes,
|
||||
outdoor: data.tags.outdoor,
|
||||
searchQuery: data.tags.searchQuery,
|
||||
searchType: data.tags.searchType,
|
||||
},
|
||||
} : i)));
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : "编辑失败");
|
||||
}
|
||||
}, [profile]);
|
||||
}, [profile, toast]);
|
||||
|
||||
const handleDeleteIdea = useCallback(async (ideaId: string) => {
|
||||
if (!profile) return;
|
||||
@@ -328,7 +425,9 @@ export default function BlindboxRoomPage() {
|
||||
if (!room) return null;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
|
||||
<div className={`relative flex h-dvh flex-col items-center bg-background px-5 py-6 scrollbar-none ${
|
||||
phase === "plan_reveal" ? "overflow-hidden" : "overflow-y-auto"
|
||||
}`}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex w-full max-w-sm items-center gap-3">
|
||||
@@ -341,7 +440,21 @@ export default function BlindboxRoomPage() {
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-heading">{room.name}</p>
|
||||
<p className="text-[10px] text-dim">房间 {room.code}</p>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<p className="text-[10px] text-dim">房间 {room.code}</p>
|
||||
<button
|
||||
onClick={handleSetLocation}
|
||||
disabled={locating}
|
||||
className="flex items-center gap-0.5 text-[10px] font-medium text-purple-400/70 transition-colors active:text-purple-400"
|
||||
>
|
||||
{locating ? (
|
||||
<Loader2 size={9} className="animate-spin" />
|
||||
) : (
|
||||
<MapPin size={9} />
|
||||
)}
|
||||
{room.city || "设置位置"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
@@ -424,44 +537,46 @@ export default function BlindboxRoomPage() {
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
{/* Blind Box Visual */}
|
||||
<div className="mt-8 flex flex-col items-center">
|
||||
<motion.div
|
||||
animate={boxControls}
|
||||
className="relative flex h-36 w-36 items-center justify-center"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-3xl bg-linear-to-br from-purple-600/20 to-indigo-600/20 blur-xl" />
|
||||
<div className="relative flex h-32 w-32 flex-col items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-700/30">
|
||||
<div className="absolute -top-2 left-1/2 h-5 w-[90%] -translate-x-1/2 rounded-t-xl bg-linear-to-r from-purple-700 to-indigo-700 shadow-md" />
|
||||
<div className="absolute top-3 h-full w-3 bg-linear-to-b from-amber-400/60 to-amber-400/10" />
|
||||
<div className="absolute top-6 h-3 w-full bg-linear-to-r from-amber-400/0 via-amber-400/60 to-amber-400/0" />
|
||||
<motion.div
|
||||
animate={submitFlash ? { scale: [1, 1.3, 1] } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Package size={40} className="relative z-10 text-purple-300/60" strokeWidth={1.5} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="absolute -right-2 -top-2 text-lg"
|
||||
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
|
||||
>
|
||||
✨
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Blind Box Visual — hidden during plan phases */}
|
||||
{phase !== "planning" && phase !== "plan_reveal" && (
|
||||
<div className="mt-8 flex flex-col items-center">
|
||||
<motion.div
|
||||
animate={boxControls}
|
||||
className="relative flex h-36 w-36 items-center justify-center"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-3xl bg-linear-to-br from-purple-600/20 to-indigo-600/20 blur-xl" />
|
||||
<div className="relative flex h-32 w-32 flex-col items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-700/30">
|
||||
<div className="absolute -top-2 left-1/2 h-5 w-[90%] -translate-x-1/2 rounded-t-xl bg-linear-to-r from-purple-700 to-indigo-700 shadow-md" />
|
||||
<div className="absolute top-3 h-full w-3 bg-linear-to-b from-amber-400/60 to-amber-400/10" />
|
||||
<div className="absolute top-6 h-3 w-full bg-linear-to-r from-amber-400/0 via-amber-400/60 to-amber-400/0" />
|
||||
<motion.div
|
||||
animate={submitFlash ? { scale: [1, 1.3, 1] } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Package size={40} className="relative z-10 text-purple-300/60" strokeWidth={1.5} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="absolute -right-2 -top-2 text-lg"
|
||||
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
|
||||
>
|
||||
✨
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="mt-4 text-sm font-semibold text-muted"
|
||||
key={poolCount}
|
||||
initial={{ scale: 1.2, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
>
|
||||
盒子里已有{" "}
|
||||
<span className="text-lg font-black text-purple-400">{poolCount}</span>{" "}
|
||||
个想法
|
||||
</motion.p>
|
||||
</div>
|
||||
<motion.p
|
||||
className="mt-4 text-sm font-semibold text-muted"
|
||||
key={poolCount}
|
||||
initial={{ scale: 1.2, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
>
|
||||
盒子里已有{" "}
|
||||
<span className="text-lg font-black text-purple-400">{poolCount}</span>{" "}
|
||||
个想法
|
||||
</motion.p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pool / Shaking / Reveal phases */}
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -495,17 +610,35 @@ export default function BlindboxRoomPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
onClick={handleDraw}
|
||||
disabled={poolCount === 0}
|
||||
className="relative flex h-14 w-full items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-base font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
|
||||
<Flame size={20} />
|
||||
开启周末盲盒(绝不反悔)
|
||||
</motion.button>
|
||||
<div className="flex w-full gap-2">
|
||||
<motion.button
|
||||
onClick={handleDraw}
|
||||
disabled={poolCount === 0}
|
||||
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-sm font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
|
||||
<Flame size={18} />
|
||||
抽一个
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
if (!room?.city) {
|
||||
toast.show("请先点击房间名下方设置位置");
|
||||
return;
|
||||
}
|
||||
setPhase("time_select");
|
||||
}}
|
||||
disabled={poolCount < 2}
|
||||
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-black text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50 disabled:opacity-40 disabled:shadow-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<Calendar size={18} />
|
||||
周末计划
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
@@ -613,6 +746,69 @@ export default function BlindboxRoomPage() {
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === "planning" && (
|
||||
<motion.div
|
||||
key="planning"
|
||||
className="mt-8 flex flex-col items-center gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative flex h-20 w-20 items-center justify-center"
|
||||
animate={{ rotate: [0, 360] }}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-full bg-purple-600/15 blur-lg" />
|
||||
<Sparkles size={28} className="relative text-purple-400" />
|
||||
</motion.div>
|
||||
<p className="text-sm font-bold text-purple-300 animate-pulse">
|
||||
AI 正在规划你的周末...
|
||||
</p>
|
||||
<p className="text-[11px] text-dim">搜索地点 · 优化路线 · 安排时间</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === "plan_reveal" && planDays.length > 0 && (
|
||||
<motion.div
|
||||
key="plan_reveal"
|
||||
className="mt-4 flex min-h-0 flex-1 w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<BlindboxPlan
|
||||
days={planDays}
|
||||
accepted={planAccepted}
|
||||
regenerating={generating}
|
||||
onAccept={() => {
|
||||
setPlanAccepted(true);
|
||||
fireConfetti();
|
||||
}}
|
||||
onRegenerate={() => {
|
||||
setPhase("time_select");
|
||||
}}
|
||||
onShare={() => setShowPlanShareCard(true)}
|
||||
onBack={() => {
|
||||
setPhase("pool");
|
||||
setPlanDays([]);
|
||||
setPlanAccepted(false);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Time selector modal */}
|
||||
<AnimatePresence>
|
||||
{phase === "time_select" && (
|
||||
<WeekendTimeSelector
|
||||
onConfirm={handleGeneratePlan}
|
||||
onClose={() => setPhase("pool")}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{myIdeas.length > 0 && phase === "pool" && (
|
||||
@@ -623,7 +819,7 @@ export default function BlindboxRoomPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase !== "shaking" && (
|
||||
{phase !== "shaking" && phase !== "planning" && (
|
||||
<BlindboxDrawnHistory items={drawnHistory} />
|
||||
)}
|
||||
</>
|
||||
@@ -643,8 +839,20 @@ export default function BlindboxRoomPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Leave / Delete */}
|
||||
{isMember && room && (
|
||||
{planDays.length > 0 && room && (
|
||||
<ShareCardModal
|
||||
open={showPlanShareCard}
|
||||
onClose={() => setShowPlanShareCard(false)}
|
||||
data={{
|
||||
type: "plan",
|
||||
days: planDays,
|
||||
roomName: room.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Leave / Delete — hidden during plan view */}
|
||||
{isMember && room && phase !== "plan_reveal" && phase !== "planning" && (
|
||||
<motion.div
|
||||
className="mt-12 w-full max-w-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
|
||||
@@ -2,12 +2,64 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Package, Loader2, Pencil, Trash2, Check, X } from "lucide-react";
|
||||
import {
|
||||
Package,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
UtensilsCrossed,
|
||||
TreePine,
|
||||
Film,
|
||||
ShoppingBag,
|
||||
Dumbbell,
|
||||
Landmark,
|
||||
Coffee,
|
||||
} from "lucide-react";
|
||||
import type { IdeaCategory } from "@/types";
|
||||
|
||||
export interface MyIdea {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
category?: string | null;
|
||||
timeSlot?: string | null;
|
||||
estimatedMinutes?: number | null;
|
||||
outdoor?: boolean | null;
|
||||
searchQuery?: string | null;
|
||||
searchType?: string | null;
|
||||
}
|
||||
|
||||
const CATEGORY_CONFIG: Record<
|
||||
IdeaCategory,
|
||||
{ icon: typeof UtensilsCrossed; color: string; label: string }
|
||||
> = {
|
||||
dining: { icon: UtensilsCrossed, color: "text-orange-400", label: "美食" },
|
||||
outdoor: { icon: TreePine, color: "text-emerald-400", label: "户外" },
|
||||
entertainment: { icon: Film, color: "text-sky-400", label: "娱乐" },
|
||||
shopping: { icon: ShoppingBag, color: "text-pink-400", label: "购物" },
|
||||
sports: { icon: Dumbbell, color: "text-amber-400", label: "运动" },
|
||||
culture: { icon: Landmark, color: "text-violet-400", label: "文化" },
|
||||
relaxation: { icon: Coffee, color: "text-teal-400", label: "休闲" },
|
||||
};
|
||||
|
||||
function CategoryBadge({ category }: { category?: string | null }) {
|
||||
if (!category) return <span className="text-sm">💡</span>;
|
||||
const cfg = CATEGORY_CONFIG[category as IdeaCategory];
|
||||
if (!cfg) return <span className="text-sm">💡</span>;
|
||||
const Icon = cfg.icon;
|
||||
return <Icon size={14} className={`shrink-0 ${cfg.color}`} />;
|
||||
}
|
||||
|
||||
function DurationLabel({ minutes }: { minutes?: number | null }) {
|
||||
if (!minutes) return null;
|
||||
const display = minutes >= 60 ? `${(minutes / 60).toFixed(minutes % 60 === 0 ? 0 : 1)}h` : `${minutes}min`;
|
||||
return (
|
||||
<span className="shrink-0 rounded-md bg-elevated px-1.5 py-0.5 text-[10px] font-medium text-dim">
|
||||
~{display}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MyIdeaItem({
|
||||
@@ -66,8 +118,9 @@ function MyIdeaItem({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm">💡</span>
|
||||
<CategoryBadge category={idea.category} />
|
||||
<p className="min-w-0 flex-1 truncate text-sm text-secondary">{idea.content}</p>
|
||||
<DurationLabel minutes={idea.estimatedMinutes} />
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted transition-colors active:bg-elevated active:text-purple-400"
|
||||
@@ -119,3 +172,5 @@ export default function BlindboxMyIdeas({
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CATEGORY_CONFIG, CategoryBadge, DurationLabel };
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
MapPin,
|
||||
Clock,
|
||||
Navigation,
|
||||
Share2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
CornerDownLeft,
|
||||
} from "lucide-react";
|
||||
import { CategoryBadge } from "@/components/BlindboxMyIdeas";
|
||||
import Button from "@/components/Button";
|
||||
import type { WeekendPlanData } from "@/types";
|
||||
|
||||
interface BlindboxPlanProps {
|
||||
days: WeekendPlanData[];
|
||||
onAccept: () => void;
|
||||
onRegenerate: () => void;
|
||||
onShare: () => void;
|
||||
onBack: () => void;
|
||||
accepted?: boolean;
|
||||
regenerating?: boolean;
|
||||
}
|
||||
|
||||
function guessCategory(activity: string): string | null {
|
||||
const lower = activity.toLowerCase();
|
||||
if (/吃|餐|饭|火锅|烧烤|面|菜|厨|食/.test(lower)) return "dining";
|
||||
if (/公园|山|湖|海|户外|骑|徒步|露营/.test(lower)) return "outdoor";
|
||||
if (/电影|KTV|密室|游戏|桌游|剧/.test(lower)) return "entertainment";
|
||||
if (/逛街|购物|商场|买/.test(lower)) return "shopping";
|
||||
if (/运动|健身|球|跑|游泳|瑜伽/.test(lower)) return "sports";
|
||||
if (/博物馆|展览|美术|书/.test(lower)) return "culture";
|
||||
if (/咖啡|茶|SPA|按摩|下午茶/.test(lower)) return "relaxation";
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes >= 60) {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h${m}min` : `${h}h`;
|
||||
}
|
||||
return `${minutes}min`;
|
||||
}
|
||||
|
||||
export default function BlindboxPlan({
|
||||
days,
|
||||
onAccept,
|
||||
onRegenerate,
|
||||
onShare,
|
||||
onBack,
|
||||
accepted,
|
||||
regenerating,
|
||||
}: BlindboxPlanProps) {
|
||||
const [dayIndex, setDayIndex] = useState(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const currentDay = days[dayIndex];
|
||||
const hasNext = dayIndex < days.length - 1;
|
||||
const hasPrev = dayIndex > 0;
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [dayIndex]);
|
||||
|
||||
if (!currentDay) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Day header — sticky top */}
|
||||
<div className="shrink-0 pb-3 text-center">
|
||||
<motion.div
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-purple-600/15 px-3 py-1 text-xs font-bold text-purple-400"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
{currentDay.date} · 行程规划
|
||||
</motion.div>
|
||||
|
||||
{days.length > 1 && (
|
||||
<div className="mt-2 flex items-center justify-center gap-1.5">
|
||||
{days.map((day, i) => (
|
||||
<button
|
||||
key={day.date}
|
||||
onClick={() => setDayIndex(i)}
|
||||
className={`rounded-full transition-all ${
|
||||
i === dayIndex
|
||||
? "h-1.5 w-5 bg-purple-400"
|
||||
: "h-1.5 w-1.5 bg-purple-400/25"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentDay.summary && (
|
||||
<motion.p
|
||||
className="mt-2 text-xs text-muted"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
{currentDay.summary}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable timeline */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-none"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={dayIndex}
|
||||
className="relative mx-auto max-w-sm pl-6"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div className="absolute left-[9px] top-2 bottom-2 w-px bg-purple-500/20" />
|
||||
|
||||
{currentDay.items.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="relative mb-4 last:mb-0"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.08 }}
|
||||
>
|
||||
<div className="absolute -left-6 top-3 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-purple-600/20 ring-2 ring-background">
|
||||
<div className="h-2 w-2 rounded-full bg-purple-400" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-surface/80 p-3.5 ring-1 ring-border/80">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 text-sm font-black text-purple-400">{item.time}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CategoryBadge category={guessCategory(item.activity)} />
|
||||
<p className="truncate text-sm font-bold text-heading">{item.activity}</p>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1 text-[11px] text-muted">
|
||||
<MapPin size={10} className="shrink-0" />
|
||||
<span className="truncate">{item.poi}</span>
|
||||
</div>
|
||||
{item.address && (
|
||||
<p className="mt-0.5 truncate text-[10px] text-dim">{item.address}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[10px] text-dim">
|
||||
<Clock size={9} />
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
{item.lat !== 0 && item.lng !== 0 && (
|
||||
<a
|
||||
href={`https://uri.amap.com/marker?position=${item.lng},${item.lat}&name=${encodeURIComponent(item.poi)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-[10px] font-medium text-purple-400/70 active:text-purple-400"
|
||||
>
|
||||
<Navigation size={9} />
|
||||
导航
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{item.reason && (
|
||||
<p className="mt-1.5 text-[10px] leading-relaxed text-dim italic">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Back to pool — at end of scroll content */}
|
||||
<div className="mt-6 flex justify-center pb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted active:text-foreground"
|
||||
>
|
||||
<CornerDownLeft size={12} />
|
||||
返回想法池
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed bottom bar — actions + day navigation */}
|
||||
<div className="shrink-0 border-t border-border/40 bg-background/80 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur-lg">
|
||||
{/* Day navigation */}
|
||||
{days.length > 1 && (
|
||||
<div className="mx-auto mb-2.5 flex max-w-sm items-center justify-center gap-2 px-4">
|
||||
{hasPrev && (
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
onClick={() => setDayIndex(dayIndex - 1)}
|
||||
className="flex items-center gap-1 rounded-full bg-surface px-3 py-1.5 text-[11px] font-bold text-purple-400 ring-1 ring-border/60 active:bg-elevated"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
{days[dayIndex - 1].date}
|
||||
</motion.button>
|
||||
)}
|
||||
<span className="text-[10px] text-dim">
|
||||
{dayIndex + 1} / {days.length}
|
||||
</span>
|
||||
{hasNext && (
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
onClick={() => setDayIndex(dayIndex + 1)}
|
||||
className="flex items-center gap-1 rounded-full bg-purple-600/15 px-3 py-1.5 text-[11px] font-bold text-purple-400 active:bg-purple-600/25"
|
||||
>
|
||||
{days[dayIndex + 1].date}
|
||||
<ChevronRight size={12} />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mx-auto flex max-w-sm items-center justify-center gap-3 px-4">
|
||||
{accepted ? (
|
||||
<Button
|
||||
onClick={onShare}
|
||||
variant="purple"
|
||||
shape="pill"
|
||||
icon={<Share2 size={14} />}
|
||||
>
|
||||
分享计划
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={onAccept}
|
||||
variant="purple"
|
||||
shape="pill"
|
||||
icon={<Sparkles size={14} />}
|
||||
>
|
||||
接受契约
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onRegenerate}
|
||||
variant="secondary"
|
||||
shape="pill"
|
||||
loading={regenerating}
|
||||
icon={<RefreshCw size={14} />}
|
||||
>
|
||||
换一个方案
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import type { WeekendPlanData } from "@/types";
|
||||
|
||||
export interface PlanShareData {
|
||||
type: "plan";
|
||||
days: WeekendPlanData[];
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
export default function BlindboxPlanShareCard({
|
||||
data,
|
||||
cardRef,
|
||||
}: {
|
||||
data: PlanShareData;
|
||||
cardRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const { days, roomName } = data;
|
||||
const shareUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
style={{
|
||||
width: 340,
|
||||
padding: 1.5,
|
||||
borderRadius: 20,
|
||||
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 18.5,
|
||||
background: "#0a0810",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Decorative glows */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -30,
|
||||
right: -20,
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Brand header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>📋</span>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
NoWhatever
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
letterSpacing: "0.15em",
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
别说随便 · WEEKEND PLAN
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thin accent line */}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: "0 20px",
|
||||
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Each day */}
|
||||
{days.map((day, dayIdx) => (
|
||||
<div key={day.date}>
|
||||
{/* Room + date badge */}
|
||||
<div style={{ textAlign: "center", padding: "16px 20px 8px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.25em",
|
||||
color: "rgba(167,139,250,0.5)",
|
||||
}}
|
||||
>
|
||||
✦ {roomName} · {day.date} ✦
|
||||
</div>
|
||||
{day.summary && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 6,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{day.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline items */}
|
||||
<div style={{ padding: "12px 20px 20px" }}>
|
||||
{day.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
marginBottom: i < day.items.length - 1 ? 12 : 0,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 900,
|
||||
color: "#a78bfa",
|
||||
textAlign: "right",
|
||||
paddingTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.time}
|
||||
</div>
|
||||
|
||||
<div style={{ width: 16, flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#7c3aed",
|
||||
marginTop: 5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{i < day.items.length - 1 && (
|
||||
<div style={{ width: 1, flex: 1, background: "rgba(124,58,237,0.2)", marginTop: 4 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#ffffff" }}>
|
||||
{item.activity}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "rgba(167,139,250,0.5)", marginTop: 3 }}>
|
||||
📍 {item.poi}
|
||||
</div>
|
||||
{item.reason && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: "rgba(255,255,255,0.25)",
|
||||
marginTop: 3,
|
||||
fontStyle: "italic",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{item.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Separator between days */}
|
||||
{dayIdx < days.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: "0 20px",
|
||||
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.15), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Contract stamp */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "0 20px 16px",
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: "rgba(167,139,250,0.3)",
|
||||
}}
|
||||
>
|
||||
此契约一旦开启,绝不反悔
|
||||
</div>
|
||||
|
||||
{/* QR footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 5,
|
||||
borderRadius: 8,
|
||||
background: "#ffffff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={shareUrl}
|
||||
size={52}
|
||||
level="M"
|
||||
bgColor="#ffffff"
|
||||
fgColor="#0a0810"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "rgba(255,255,255,0.7)" }}>
|
||||
扫码一起「别说随便」
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.2)", marginTop: 3 }}>
|
||||
{shareUrl.replace(/^https?:\/\//, "")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,11 @@ import RestaurantShareCard, {
|
||||
import BlindboxShareCard, {
|
||||
type BlindboxShareData,
|
||||
} from "@/components/BlindboxShareCard";
|
||||
import BlindboxPlanShareCard, {
|
||||
type PlanShareData,
|
||||
} from "@/components/BlindboxPlanShareCard";
|
||||
|
||||
export type ShareCardData = RestaurantShareData | BlindboxShareData;
|
||||
export type ShareCardData = RestaurantShareData | BlindboxShareData | PlanShareData;
|
||||
|
||||
interface ShareCardModalProps {
|
||||
open: boolean;
|
||||
@@ -151,6 +154,8 @@ export default function ShareCardModal({
|
||||
cardRef={cardRef}
|
||||
imageDataUrl={imageDataUrl}
|
||||
/>
|
||||
) : data.type === "plan" ? (
|
||||
<BlindboxPlanShareCard data={data} cardRef={cardRef} />
|
||||
) : (
|
||||
<BlindboxShareCard data={data} cardRef={cardRef} />
|
||||
)}
|
||||
@@ -174,7 +179,7 @@ export default function ShareCardModal({
|
||||
onClick={handleShare}
|
||||
disabled={generating}
|
||||
className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-2xl text-sm font-bold text-white shadow-lg transition-colors disabled:opacity-50 ${
|
||||
data.type === "blindbox"
|
||||
data.type === "blindbox" || data.type === "plan"
|
||||
? "bg-purple-600 shadow-purple-900/30 active:bg-purple-500"
|
||||
: "bg-emerald-600 shadow-emerald-900/30 active:bg-emerald-500"
|
||||
}`}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Calendar, Clock, Sparkles, X } from "lucide-react";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
interface TimeConfig {
|
||||
date: string;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
const PRESETS: { label: string; value: TimeConfig }[] = [
|
||||
{ label: "周六全天", value: { date: "周六", startHour: 10, endHour: 21 } },
|
||||
{ label: "周日全天", value: { date: "周日", startHour: 10, endHour: 21 } },
|
||||
{ label: "整个周末", value: { date: "整个周末", startHour: 10, endHour: 21 } },
|
||||
];
|
||||
|
||||
const HOURS = Array.from({ length: 15 }, (_, i) => i + 7);
|
||||
|
||||
export default function WeekendTimeSelector({
|
||||
onConfirm,
|
||||
onClose,
|
||||
loading,
|
||||
}: {
|
||||
onConfirm: (config: TimeConfig) => void;
|
||||
onClose: () => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const [config, setConfig] = useState<TimeConfig>(PRESETS[0].value);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
className="w-full max-w-md rounded-t-3xl bg-surface px-6 pb-8 pt-4"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mx-auto mb-4 h-1 w-10 rounded-full bg-border" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={16} className="text-purple-400" />
|
||||
<h3 className="text-sm font-bold text-heading">选择可用时间</h3>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="关闭" className="text-muted active:text-foreground">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.value.date}
|
||||
onClick={() => setConfig({ ...preset.value })}
|
||||
className={`flex-1 rounded-xl py-2.5 text-xs font-bold transition-all ${
|
||||
config.date === preset.value.date
|
||||
? "bg-purple-600 text-white shadow-lg shadow-purple-900/30"
|
||||
: "bg-elevated text-secondary ring-1 ring-border"
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Clock size={14} className="shrink-0 text-muted" />
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<select
|
||||
value={config.startHour}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, startHour: Number(e.target.value) }))}
|
||||
className="h-9 flex-1 rounded-lg bg-elevated px-2 text-center text-sm font-semibold text-foreground ring-1 ring-border"
|
||||
>
|
||||
{HOURS.filter((h) => h < config.endHour).map((h) => (
|
||||
<option key={h} value={h}>{String(h).padStart(2, "0")}:00</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted">至</span>
|
||||
<select
|
||||
value={config.endHour}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, endHour: Number(e.target.value) }))}
|
||||
className="h-9 flex-1 rounded-lg bg-elevated px-2 text-center text-sm font-semibold text-foreground ring-1 ring-border"
|
||||
>
|
||||
{HOURS.filter((h) => h > config.startHour).map((h) => (
|
||||
<option key={h} value={h}>{String(h).padStart(2, "0")}:00</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => onConfirm(config)}
|
||||
variant="purple"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
icon={<Sparkles size={16} />}
|
||||
className="mt-6 w-full"
|
||||
>
|
||||
生成周末计划
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+172
@@ -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<IdeaTags | null> {
|
||||
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<string, unknown>) => ({
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user