refactor(P2/P3): 完成全部7批重构 — 模块化、SSE退避、无障碍、Zod校验、Server组件、Room关系化
批次A:重命名 + 路由拆分 - store.ts → roomRepository.ts,更新全部 import - blindbox/plan/route.ts 精简为薄路由,业务逻辑抽取到 planActions.ts / planQueries.ts 批次B:blindboxPlanGen.ts 拆分(710行 → src/lib/plan/) - agentPlan.ts:Agent 工具调用与系统提示 - legacyPlan.ts:非 Agent 备用生成逻辑 - ideaSelection.ts:Idea 筛选与 Slot 映射 - transitEnrichment.ts:交通信息查询与填充 - index.ts:runPlanGeneration 主入口 批次C:SSE 连接稳定性 - useRoomPolling.ts 加入指数退避重连(上限60s,含Jitter) - plan/stream/route.ts 添加30s心跳 + abort信号清理 批次D:无障碍修复 - Modal:role=dialog、aria-modal、aria-labelledby - AuthModal:aria-label关闭按钮、tablist/tab/aria-selected - PlanItemEditModal、QrInviteModal:补全aria-label - BlindboxPlan:图标按钮aria-label 批次E:Zod 引入 - src/lib/schemas/ai.ts:AI返回值 Schema(IdeaTagsSchema等5个) - src/lib/schemas/requests.ts:请求体 Schema - ai.ts 手工验证替换为 Zod safeParse 批次F:Server Components - achievements/page.tsx → Server Component + AchievementsClient.tsx - profile/page.tsx → Server Component + ProfileClient.tsx 批次G:Room 关系化模型 - prisma/schema.prisma:新增 RoomMember、RoomRestaurant、RoomLike、RoomSwipe 4张表 - migration:20260302010000_room_relational_model - roomRepository.ts 完整重写(关系查询+应用锁) - buildRoomStatus.ts 适配关系查询 测试:全部329个用例通过,修复68个因auth mock缺失导致的测试失败
This commit is contained in:
+2
-1
@@ -29,7 +29,8 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"swr": "^2.4.0"
|
"swr": "^2.4.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- Drop old Room table and recreate with new relational schema
|
||||||
|
-- Room data is ephemeral (24h TTL), so no data migration needed.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS "Room";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Room" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"creatorId" TEXT NOT NULL DEFAULT '',
|
||||||
|
"scene" TEXT NOT NULL DEFAULT 'eat',
|
||||||
|
"locked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"match" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Room_expiresAt_idx" ON "Room"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoomMember" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"roomId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"kicked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "RoomMember_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RoomMember_roomId_userId_key" ON "RoomMember"("roomId", "userId");
|
||||||
|
CREATE INDEX "RoomMember_roomId_idx" ON "RoomMember"("roomId");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoomRestaurant" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"roomId" TEXT NOT NULL,
|
||||||
|
"restaurantData" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "RoomRestaurant_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoomRestaurant_roomId_idx" ON "RoomRestaurant"("roomId");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoomLike" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"roomId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"restaurantId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "RoomLike_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RoomLike_roomId_userId_restaurantId_key" ON "RoomLike"("roomId", "userId", "restaurantId");
|
||||||
|
CREATE INDEX "RoomLike_roomId_idx" ON "RoomLike"("roomId");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoomSwipe" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"roomId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT "RoomSwipe_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RoomSwipe_roomId_userId_key" ON "RoomSwipe"("roomId", "userId");
|
||||||
|
CREATE INDEX "RoomSwipe_roomId_idx" ON "RoomSwipe"("roomId");
|
||||||
+56
-1
@@ -9,13 +9,68 @@ generator client {
|
|||||||
|
|
||||||
model Room {
|
model Room {
|
||||||
id String @id
|
id String @id
|
||||||
data String
|
creatorId String
|
||||||
|
scene String @default("eat")
|
||||||
|
locked Boolean @default(false)
|
||||||
|
match String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
|
|
||||||
|
members RoomMember[]
|
||||||
|
restaurants RoomRestaurant[]
|
||||||
|
likes RoomLike[]
|
||||||
|
swipes RoomSwipe[]
|
||||||
|
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model RoomMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
roomId String
|
||||||
|
userId String
|
||||||
|
kicked Boolean @default(false)
|
||||||
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
|
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([roomId, userId])
|
||||||
|
@@index([roomId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RoomRestaurant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
roomId String
|
||||||
|
restaurantData String
|
||||||
|
|
||||||
|
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([roomId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RoomLike {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
roomId String
|
||||||
|
userId String
|
||||||
|
restaurantId String
|
||||||
|
|
||||||
|
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([roomId, userId, restaurantId])
|
||||||
|
@@index([roomId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RoomSwipe {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
roomId String
|
||||||
|
userId String
|
||||||
|
count Int @default(0)
|
||||||
|
|
||||||
|
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([roomId, userId])
|
||||||
|
@@index([roomId])
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Trophy,
|
||||||
|
Zap,
|
||||||
|
Gift,
|
||||||
|
ClipboardList,
|
||||||
|
Target,
|
||||||
|
TrendingUp,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
import RestaurantImage from "@/components/RestaurantImage";
|
||||||
|
import ContractHistoryItem from "@/components/ContractHistoryItem";
|
||||||
|
import EmptyState from "@/components/EmptyState";
|
||||||
|
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
||||||
|
import { buildNavUrl } from "@/lib/navigation";
|
||||||
|
import { useAchievements } from "@/hooks/useAchievements";
|
||||||
|
import type { Restaurant } from "@/types";
|
||||||
|
|
||||||
|
type Tab = "decisions" | "contracts";
|
||||||
|
|
||||||
|
function firstImage(r: Restaurant): string {
|
||||||
|
if (r.images?.length > 0) return r.images[0];
|
||||||
|
const legacy = (r as unknown as Record<string, unknown>).image;
|
||||||
|
return typeof legacy === "string" ? legacy : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AchievementsPage({ initialUserId }: { initialUserId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [tab, setTab] = useState<Tab>("decisions");
|
||||||
|
const [userId] = useState<string | undefined>(initialUserId || undefined);
|
||||||
|
|
||||||
|
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: "决策记录",
|
||||||
|
value: stats.totalDecisions,
|
||||||
|
icon: Target,
|
||||||
|
color: "text-amber-400",
|
||||||
|
bg: "bg-amber-600/15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "契约完成",
|
||||||
|
value: stats.completedContracts,
|
||||||
|
icon: Trophy,
|
||||||
|
color: "text-emerald-400",
|
||||||
|
bg: "bg-emerald-600/15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "完成率",
|
||||||
|
value: stats.totalContracts > 0 ? `${stats.completionRate}%` : "—",
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: "text-purple-400",
|
||||||
|
bg: "bg-purple-600/15",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string; icon: typeof Zap }[] = [
|
||||||
|
{ id: "decisions", label: "极速救场", icon: Zap },
|
||||||
|
{ id: "contracts", label: "周末契约", icon: Gift },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
|
||||||
|
{/* Ambient glow */}
|
||||||
|
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[320px] w-[320px] rounded-full bg-purple-500/8 blur-3xl" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex w-full max-w-sm items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="返回"
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} className="text-muted" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trophy size={18} className="text-amber-400" />
|
||||||
|
<h1 className="text-base font-bold text-heading">成就墙</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-6 grid w-full max-w-sm grid-cols-3 gap-3"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{statCards.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
className="flex flex-col items-center gap-1.5 rounded-xl bg-surface p-3 ring-1 ring-border"
|
||||||
|
>
|
||||||
|
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${s.bg}`}>
|
||||||
|
<s.icon size={16} className={s.color} />
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-5 w-10" />
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-black text-heading">{s.value}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Tab switcher */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-6 flex w-full max-w-sm rounded-xl bg-surface p-1 ring-1 ring-border"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors ${
|
||||||
|
tab === t.id ? "text-heading" : "text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab === t.id && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeTab"
|
||||||
|
className="absolute inset-0 rounded-lg bg-elevated ring-1 ring-border"
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 350 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<t.icon size={13} className="relative z-10" />
|
||||||
|
<span className="relative z-10">{t.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="mt-4 w-full max-w-sm">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{tab === "decisions" && (
|
||||||
|
<motion.div
|
||||||
|
key="decisions"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
</>
|
||||||
|
) : decisions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardList}
|
||||||
|
image="/empty-no-record.png"
|
||||||
|
title="还没有决策记录"
|
||||||
|
subtitle="使用极速救场后会在这里记录"
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
decisions.map((d) => (
|
||||||
|
<a
|
||||||
|
key={d.id}
|
||||||
|
href={buildNavUrl(d.restaurantData)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
|
||||||
|
>
|
||||||
|
{firstImage(d.restaurantData) && (
|
||||||
|
<RestaurantImage
|
||||||
|
src={firstImage(d.restaurantData)}
|
||||||
|
alt={d.restaurantName}
|
||||||
|
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||||
|
<p className="truncate text-sm font-semibold text-heading">
|
||||||
|
{d.restaurantName}
|
||||||
|
</p>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
|
||||||
|
<span>
|
||||||
|
{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}
|
||||||
|
</span>
|
||||||
|
<span>{d.participants} 人参与</span>
|
||||||
|
<span>
|
||||||
|
{new Date(d.createdAt).toLocaleDateString("zh-CN", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "contracts" && (
|
||||||
|
<motion.div
|
||||||
|
key="contracts"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
</>
|
||||||
|
) : contracts.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
image="/empty-no-room.png"
|
||||||
|
title="还没有契约记录"
|
||||||
|
subtitle="完成或过期的契约会在这里显示"
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
contracts.map((c) => (
|
||||||
|
<ContractHistoryItem key={c.id} record={c} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-8 shrink-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
-248
@@ -1,252 +1,20 @@
|
|||||||
"use client";
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { verifyToken } from "@/lib/auth";
|
||||||
|
import AchievementsClient from "./AchievementsClient";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
export default async function AchievementsPage() {
|
||||||
import { useRouter } from "next/navigation";
|
const cookieStore = await cookies();
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
const token = cookieStore.get("nw_token")?.value;
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Trophy,
|
|
||||||
Zap,
|
|
||||||
Gift,
|
|
||||||
ClipboardList,
|
|
||||||
Target,
|
|
||||||
TrendingUp,
|
|
||||||
BarChart3,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { isRegistered, getCachedProfile } from "@/lib/userId";
|
|
||||||
import RestaurantImage from "@/components/RestaurantImage";
|
|
||||||
import ContractHistoryItem from "@/components/ContractHistoryItem";
|
|
||||||
import EmptyState from "@/components/EmptyState";
|
|
||||||
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
|
||||||
import { buildNavUrl } from "@/lib/navigation";
|
|
||||||
import { useAchievements } from "@/hooks/useAchievements";
|
|
||||||
import type { Restaurant } from "@/types";
|
|
||||||
|
|
||||||
type Tab = "decisions" | "contracts";
|
if (!token) redirect("/");
|
||||||
|
|
||||||
function firstImage(r: Restaurant): string {
|
let userId: string;
|
||||||
if (r.images?.length > 0) return r.images[0];
|
try {
|
||||||
const legacy = (r as unknown as Record<string, unknown>).image;
|
userId = await verifyToken(token);
|
||||||
return typeof legacy === "string" ? legacy : "";
|
} catch {
|
||||||
}
|
redirect("/");
|
||||||
|
}
|
||||||
export default function AchievementsPage() {
|
|
||||||
const router = useRouter();
|
return <AchievementsClient initialUserId={userId} />;
|
||||||
const [tab, setTab] = useState<Tab>("decisions");
|
|
||||||
const [userId, setUserId] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isRegistered()) {
|
|
||||||
router.replace("/");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const p = getCachedProfile();
|
|
||||||
if (p) setUserId(p.id);
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{
|
|
||||||
label: "决策记录",
|
|
||||||
value: stats.totalDecisions,
|
|
||||||
icon: Target,
|
|
||||||
color: "text-amber-400",
|
|
||||||
bg: "bg-amber-600/15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "契约完成",
|
|
||||||
value: stats.completedContracts,
|
|
||||||
icon: Trophy,
|
|
||||||
color: "text-emerald-400",
|
|
||||||
bg: "bg-emerald-600/15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "完成率",
|
|
||||||
value: stats.totalContracts > 0 ? `${stats.completionRate}%` : "—",
|
|
||||||
icon: TrendingUp,
|
|
||||||
color: "text-purple-400",
|
|
||||||
bg: "bg-purple-600/15",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string; icon: typeof Zap }[] = [
|
|
||||||
{ id: "decisions", label: "极速救场", icon: Zap },
|
|
||||||
{ id: "contracts", label: "周末契约", icon: Gift },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
|
|
||||||
{/* Ambient glow */}
|
|
||||||
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[320px] w-[320px] rounded-full bg-purple-500/8 blur-3xl" />
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex w-full max-w-sm items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => router.back()}
|
|
||||||
aria-label="返回"
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={16} className="text-muted" />
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Trophy size={18} className="text-amber-400" />
|
|
||||||
<h1 className="text-base font-bold text-heading">成就墙</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<motion.div
|
|
||||||
className="mt-6 grid w-full max-w-sm grid-cols-3 gap-3"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
>
|
|
||||||
{statCards.map((s) => (
|
|
||||||
<div
|
|
||||||
key={s.label}
|
|
||||||
className="flex flex-col items-center gap-1.5 rounded-xl bg-surface p-3 ring-1 ring-border"
|
|
||||||
>
|
|
||||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${s.bg}`}>
|
|
||||||
<s.icon size={16} className={s.color} />
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<Skeleton className="h-5 w-10" />
|
|
||||||
) : (
|
|
||||||
<p className="text-lg font-black text-heading">{s.value}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted">{s.label}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Tab switcher */}
|
|
||||||
<motion.div
|
|
||||||
className="mt-6 flex w-full max-w-sm rounded-xl bg-surface p-1 ring-1 ring-border"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
>
|
|
||||||
{tabs.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => setTab(t.id)}
|
|
||||||
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors ${
|
|
||||||
tab === t.id ? "text-heading" : "text-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab === t.id && (
|
|
||||||
<motion.div
|
|
||||||
layoutId="activeTab"
|
|
||||||
className="absolute inset-0 rounded-lg bg-elevated ring-1 ring-border"
|
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 350 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<t.icon size={13} className="relative z-10" />
|
|
||||||
<span className="relative z-10">{t.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="mt-4 w-full max-w-sm">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{tab === "decisions" && (
|
|
||||||
<motion.div
|
|
||||||
key="decisions"
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
</>
|
|
||||||
) : decisions.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={ClipboardList}
|
|
||||||
image="/empty-no-record.png"
|
|
||||||
title="还没有决策记录"
|
|
||||||
subtitle="使用极速救场后会在这里记录"
|
|
||||||
color="amber"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
decisions.map((d) => (
|
|
||||||
<a
|
|
||||||
key={d.id}
|
|
||||||
href={buildNavUrl(d.restaurantData)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
|
|
||||||
>
|
|
||||||
{firstImage(d.restaurantData) && (
|
|
||||||
<RestaurantImage
|
|
||||||
src={firstImage(d.restaurantData)}
|
|
||||||
alt={d.restaurantName}
|
|
||||||
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
|
||||||
<p className="truncate text-sm font-semibold text-heading">
|
|
||||||
{d.restaurantName}
|
|
||||||
</p>
|
|
||||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
|
|
||||||
<span>
|
|
||||||
{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}
|
|
||||||
</span>
|
|
||||||
<span>{d.participants} 人参与</span>
|
|
||||||
<span>
|
|
||||||
{new Date(d.createdAt).toLocaleDateString("zh-CN", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "contracts" && (
|
|
||||||
<motion.div
|
|
||||||
key="contracts"
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -20 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
</>
|
|
||||||
) : contracts.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={BarChart3}
|
|
||||||
image="/empty-no-room.png"
|
|
||||||
title="还没有契约记录"
|
|
||||||
subtitle="完成或过期的契约会在这里显示"
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
contracts.map((c) => (
|
|
||||||
<ContractHistoryItem key={c.id} record={c} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-8 shrink-0" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_USER } from "@/__tests__/helpers/fixtures";
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/blindbox", () => ({
|
vi.mock("@/lib/blindbox", () => ({
|
||||||
requireMembership: vi.fn().mockResolvedValue({}),
|
requireMembership: vi.fn().mockResolvedValue({}),
|
||||||
@@ -36,7 +39,7 @@ describe("POST /api/blindbox/draw", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/draw", {
|
const req = createRequest("/api/blindbox/draw", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
body: { roomId: "bb-room-1" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -62,7 +65,7 @@ describe("POST /api/blindbox/draw", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/draw", {
|
const req = createRequest("/api/blindbox/draw", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
body: { roomId: "bb-room-1" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
@@ -82,7 +85,7 @@ describe("POST /api/blindbox/draw", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/draw", {
|
const req = createRequest("/api/blindbox/draw", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
body: { roomId: "bb-room-1" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
@@ -91,7 +94,7 @@ describe("POST /api/blindbox/draw", () => {
|
|||||||
it("returns 400 when roomId is missing", async () => {
|
it("returns 400 when roomId is missing", async () => {
|
||||||
const req = createRequest("/api/blindbox/draw", {
|
const req = createRequest("/api/blindbox/draw", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1" },
|
body: {},
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
|||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_WEEKEND_PLAN } from "@/__tests__/helpers/fixtures";
|
import { TEST_WEEKEND_PLAN } from "@/__tests__/helpers/fixtures";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/blindbox", () => ({
|
vi.mock("@/lib/blindbox", () => ({
|
||||||
requireMembership: vi.fn().mockResolvedValue({}),
|
requireMembership: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
@@ -29,7 +33,6 @@ describe("POST /api/blindbox/plan", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
roomId: "bb-room-1",
|
roomId: "bb-room-1",
|
||||||
userId: "user-1",
|
|
||||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -45,7 +48,6 @@ describe("POST /api/blindbox/plan", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
roomId: "bb-room-1",
|
roomId: "bb-room-1",
|
||||||
userId: "user-1",
|
|
||||||
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
|
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -57,7 +59,6 @@ describe("POST /api/blindbox/plan", () => {
|
|||||||
const req = createRequest("/api/blindbox/plan", {
|
const req = createRequest("/api/blindbox/plan", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
userId: "user-1",
|
|
||||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -76,7 +77,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/plan", {
|
const req = createRequest("/api/blindbox/plan", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { planId: "plan-1", userId: "user-1", action: "accept" },
|
body: { planId: "plan-1", action: "accept" },
|
||||||
});
|
});
|
||||||
const res = await PATCH(req, mockCtx);
|
const res = await PATCH(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -94,7 +95,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/plan", {
|
const req = createRequest("/api/blindbox/plan", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { planId: "plan-1", userId: "user-1", action: "complete" },
|
body: { planId: "plan-1", action: "complete" },
|
||||||
});
|
});
|
||||||
const res = await PATCH(req, mockCtx);
|
const res = await PATCH(req, mockCtx);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -109,7 +110,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/plan", {
|
const req = createRequest("/api/blindbox/plan", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { planId: "plan-1", userId: "user-1", action: "expire" },
|
body: { planId: "plan-1", action: "expire" },
|
||||||
});
|
});
|
||||||
const res = await PATCH(req, mockCtx);
|
const res = await PATCH(req, mockCtx);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -123,7 +124,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/plan", {
|
const req = createRequest("/api/blindbox/plan", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { planId: "plan-1", userId: "user-1", action: "accept" },
|
body: { planId: "plan-1", action: "accept" },
|
||||||
});
|
});
|
||||||
const res = await PATCH(req, mockCtx);
|
const res = await PATCH(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -137,7 +138,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/plan", {
|
const req = createRequest("/api/blindbox/plan", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { planId: "plan-1", userId: "user-1", action: "accept" },
|
body: { planId: "plan-1", action: "accept" },
|
||||||
});
|
});
|
||||||
const res = await PATCH(req, mockCtx);
|
const res = await PATCH(req, mockCtx);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
@@ -148,7 +149,7 @@ describe("PATCH /api/blindbox/plan", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/plan", {
|
const req = createRequest("/api/blindbox/plan", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { planId: "plan-1", userId: "user-1", action: "invalid" },
|
body: { planId: "plan-1", action: "invalid" },
|
||||||
});
|
});
|
||||||
const res = await PATCH(req, mockCtx);
|
const res = await PATCH(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -164,7 +165,7 @@ describe("GET /api/blindbox/plan", () => {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1");
|
const req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ describe("GET /api/blindbox/plan", () => {
|
|||||||
it("returns null when no plan found", async () => {
|
it("returns null when no plan found", async () => {
|
||||||
prismaMock.weekendPlan.findFirst.mockResolvedValue(null as never);
|
prismaMock.weekendPlan.findFirst.mockResolvedValue(null as never);
|
||||||
|
|
||||||
const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1");
|
const req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@ describe("GET /api/blindbox/plan", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 for invalid mode", async () => {
|
it("returns 400 for invalid mode", async () => {
|
||||||
const req = createRequest("/api/blindbox/plan?mode=invalid&userId=user-1");
|
const req = createRequest("/api/blindbox/plan?mode=invalid");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { requireMembership } from "@/lib/blindbox";
|
|||||||
import { apiHandler, ApiError } from "@/lib/api";
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
||||||
import { getAuthUserId } from "@/lib/auth";
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
import { handlePlanUpdate } from "@/lib/planActions";
|
||||||
|
import { getLatestPlan, getPendingPlans, getHistoryPlans } from "@/lib/planQueries";
|
||||||
|
|
||||||
interface AvailableTime {
|
interface AvailableTime {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -37,97 +39,13 @@ export const POST = apiHandler(async (req) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Map "周六"/"周日" to the next occurrence of that weekday from a reference date.
|
|
||||||
* Returns a Date at 00:00 of that day.
|
|
||||||
*/
|
|
||||||
function nextWeekday(dayLabel: string, from: Date): Date {
|
|
||||||
const targetDow = dayLabel === "周日" ? 0 : 6; // Sunday=0, Saturday=6
|
|
||||||
const d = new Date(from);
|
|
||||||
d.setHours(0, 0, 0, 0);
|
|
||||||
const diff = (targetDow - d.getDay() + 7) % 7;
|
|
||||||
d.setDate(d.getDate() + (diff === 0 ? 0 : diff));
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeEndTime(planData: string, now: Date): Date | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(planData);
|
|
||||||
const days = parsed.days as { date: string; items: { time: string; duration: number }[] }[];
|
|
||||||
if (!days?.length) return null;
|
|
||||||
|
|
||||||
const lastDay = days[days.length - 1];
|
|
||||||
const lastItem = lastDay.items[lastDay.items.length - 1];
|
|
||||||
if (!lastItem) return null;
|
|
||||||
|
|
||||||
const base = nextWeekday(lastDay.date, now);
|
|
||||||
const [h, m] = lastItem.time.split(":").map(Number);
|
|
||||||
base.setHours(h, m, 0, 0);
|
|
||||||
base.setMinutes(base.getMinutes() + (lastItem.duration || 60));
|
|
||||||
|
|
||||||
if (base.getTime() < now.getTime()) {
|
|
||||||
base.setDate(base.getDate() + 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
return base;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("computeEndTime failed:", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PATCH = apiHandler(async (req) => {
|
export const PATCH = apiHandler(async (req) => {
|
||||||
const userId = await getAuthUserId(req);
|
const userId = await getAuthUserId(req);
|
||||||
const { planId, action, days } = await req.json();
|
const { planId, action, days } = await req.json();
|
||||||
if (!planId) throw new ApiError("planId 不能为空");
|
if (!planId) throw new ApiError("planId 不能为空");
|
||||||
|
|
||||||
const { prisma } = await import("@/lib/prisma");
|
const result = await handlePlanUpdate(planId, userId, action, days);
|
||||||
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
|
return NextResponse.json(result);
|
||||||
if (!plan) throw new ApiError("计划不存在", 404);
|
|
||||||
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
|
|
||||||
|
|
||||||
const act = action || "accept";
|
|
||||||
|
|
||||||
if (act === "accept") {
|
|
||||||
if (plan.status !== "active") throw new ApiError("该计划无法接受", 400);
|
|
||||||
const endTime = computeEndTime(plan.planData, new Date());
|
|
||||||
await prisma.weekendPlan.update({
|
|
||||||
where: { id: planId },
|
|
||||||
data: { status: "accepted", endTime },
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true, endTime });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (act === "complete" || act === "expire") {
|
|
||||||
if (plan.status !== "accepted") throw new ApiError("只能更新已接受的计划", 400);
|
|
||||||
await prisma.weekendPlan.update({
|
|
||||||
where: { id: planId },
|
|
||||||
data: { status: act === "complete" ? "completed" : "expired" },
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (act === "update_plan") {
|
|
||||||
if (plan.status !== "active" && plan.status !== "accepted") {
|
|
||||||
throw new ApiError("只能编辑进行中的计划", 400);
|
|
||||||
}
|
|
||||||
if (!Array.isArray(days) || days.length === 0) {
|
|
||||||
throw new ApiError("days 数据无效", 400);
|
|
||||||
}
|
|
||||||
const newPlanData = JSON.stringify({ days });
|
|
||||||
await prisma.weekendPlan.update({
|
|
||||||
where: { id: planId },
|
|
||||||
data: {
|
|
||||||
planData: newPlanData,
|
|
||||||
...(plan.status === "accepted"
|
|
||||||
? { endTime: computeEndTime(newPlanData, new Date()) }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ApiError("无效的操作", 400);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
@@ -135,92 +53,18 @@ export const GET = apiHandler(async (req) => {
|
|||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const mode = searchParams.get("mode") || "latest";
|
const mode = searchParams.get("mode") || "latest";
|
||||||
|
|
||||||
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 不能为空");
|
||||||
|
return NextResponse.json(await getLatestPlan(roomId, userId));
|
||||||
const plan = await prisma.weekendPlan.findFirst({
|
|
||||||
where: { roomId, userId, status: "accepted" },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
select: { id: true, planData: true, endTime: true, createdAt: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!plan) return NextResponse.json({ plan: null });
|
|
||||||
const parsed = JSON.parse(plan.planData);
|
|
||||||
return NextResponse.json({
|
|
||||||
plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "pending") {
|
if (mode === "pending") {
|
||||||
const plans = await prisma.weekendPlan.findMany({
|
return NextResponse.json(await getPendingPlans(userId));
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
status: "accepted",
|
|
||||||
endTime: { not: null, lt: new Date() },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
select: { id: true, planData: true, roomId: true, createdAt: true },
|
|
||||||
take: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await Promise.all(
|
|
||||||
plans.map(async (p) => {
|
|
||||||
const room = await prisma.blindBoxRoom.findUnique({
|
|
||||||
where: { id: p.roomId },
|
|
||||||
select: { name: true, code: true },
|
|
||||||
});
|
|
||||||
const parsed = JSON.parse(p.planData);
|
|
||||||
const days = parsed.days as { date: string; items: { activity: string }[] }[];
|
|
||||||
return {
|
|
||||||
id: p.id,
|
|
||||||
roomName: room?.name ?? "未知房间",
|
|
||||||
roomCode: room?.code ?? "",
|
|
||||||
date: days.map((d) => d.date).join(" + "),
|
|
||||||
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
|
|
||||||
createdAt: p.createdAt,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ pending: result });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "history") {
|
if (mode === "history") {
|
||||||
const plans = await prisma.weekendPlan.findMany({
|
return NextResponse.json(await getHistoryPlans(userId));
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
status: { in: ["completed", "expired"] },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
select: { id: true, planData: true, status: true, roomId: true, createdAt: true },
|
|
||||||
take: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await Promise.all(
|
|
||||||
plans.map(async (p) => {
|
|
||||||
const room = await prisma.blindBoxRoom.findUnique({
|
|
||||||
where: { id: p.roomId },
|
|
||||||
select: { name: true, code: true },
|
|
||||||
});
|
|
||||||
const parsed = JSON.parse(p.planData);
|
|
||||||
const days = parsed.days as { date: string; items: { activity: string }[] }[];
|
|
||||||
return {
|
|
||||||
id: p.id,
|
|
||||||
status: p.status,
|
|
||||||
roomName: room?.name ?? "未知房间",
|
|
||||||
roomCode: room?.code ?? "",
|
|
||||||
date: days.map((d) => d.date).join(" + "),
|
|
||||||
dayCount: days.length,
|
|
||||||
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
|
|
||||||
createdAt: p.createdAt,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ history: result });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ApiError("无效的 mode 参数", 400);
|
throw new ApiError("无效的 mode 参数", 400);
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||||
|
|
||||||
vi.mock("@/lib/blindbox", () => ({
|
vi.mock("@/lib/auth", () => ({
|
||||||
requireMembership: vi.fn().mockResolvedValue({}),
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/api", () => ({
|
vi.mock("@/lib/blindbox", () => ({
|
||||||
requireUserId: vi.fn((v) => {
|
requireMembership: vi.fn().mockResolvedValue({}),
|
||||||
if (!v || typeof v !== "string") throw new Error("请先登录");
|
|
||||||
return v;
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/blindboxPlanGen", () => ({
|
vi.mock("@/lib/blindboxPlanGen", () => ({
|
||||||
@@ -19,11 +16,14 @@ vi.mock("@/lib/blindboxPlanGen", () => ({
|
|||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
const mockRunPlan = vi.mocked(runPlanGeneration);
|
const mockRunPlan = vi.mocked(runPlanGeneration);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
async function readStream(response: Response): Promise<string> {
|
async function readStream(response: Response): Promise<string> {
|
||||||
@@ -55,12 +55,11 @@ describe("POST /api/blindbox/plan/stream", () => {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
roomId: "bb-room-1",
|
roomId: "bb-room-1",
|
||||||
userId: "user-1",
|
|
||||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await POST(req);
|
const res = await POST(req as never);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||||
|
|
||||||
@@ -79,12 +78,11 @@ describe("POST /api/blindbox/plan/stream", () => {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
roomId: "bb-room-1",
|
roomId: "bb-room-1",
|
||||||
userId: "user-1",
|
|
||||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await POST(req);
|
const res = await POST(req as never);
|
||||||
const text = await readStream(res);
|
const text = await readStream(res);
|
||||||
expect(text).toContain("event: error");
|
expect(text).toContain("event: error");
|
||||||
expect(text).toContain("AI 服务不可用");
|
expect(text).toContain("AI 服务不可用");
|
||||||
@@ -95,12 +93,11 @@ describe("POST /api/blindbox/plan/stream", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
userId: "user-1",
|
|
||||||
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await POST(req);
|
const res = await POST(req as never);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,16 +107,16 @@ describe("POST /api/blindbox/plan/stream", () => {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
roomId: "bb-room-1",
|
roomId: "bb-room-1",
|
||||||
userId: "user-1",
|
|
||||||
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
|
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await POST(req);
|
const res = await POST(req as never);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when userId is missing", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = new Request("http://localhost/api/blindbox/plan/stream", {
|
const req = new Request("http://localhost/api/blindbox/plan/stream", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -129,7 +126,7 @@ describe("POST /api/blindbox/plan/stream", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await POST(req);
|
const res = await POST(req as never);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,13 +49,33 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signal = req.signal;
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
const push = (event: string, data: string) => {
|
const push = (event: string, data: string) => {
|
||||||
|
if (closed) return;
|
||||||
controller.enqueue(encoder.encode(encodeSSE(event, data)));
|
controller.enqueue(encoder.encode(encodeSSE(event, data)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
clearInterval(heartbeatId);
|
||||||
|
try { controller.close(); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 30s heartbeat to prevent proxy disconnects
|
||||||
|
const heartbeatId = setInterval(() => {
|
||||||
|
push("heartbeat", "ping");
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
// Clean up when client disconnects
|
||||||
|
signal.addEventListener("abort", cleanup);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runPlanGeneration(roomId, userId, availableTime, (message) => {
|
const result = await runPlanGeneration(roomId, userId, availableTime, (message) => {
|
||||||
push("status", message);
|
push("status", message);
|
||||||
@@ -65,7 +85,7 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|||||||
const message = e instanceof Error ? e.message : "生成计划失败";
|
const message = e instanceof Error ? e.message : "生成计划失败";
|
||||||
push("error", message);
|
push("error", message);
|
||||||
} finally {
|
} finally {
|
||||||
controller.close();
|
cleanup();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
|
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/blindbox", () => ({
|
vi.mock("@/lib/blindbox", () => ({
|
||||||
requireMembership: vi.fn().mockResolvedValue({}),
|
requireMembership: vi.fn().mockResolvedValue({}),
|
||||||
@@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPrismaMock();
|
resetPrismaMock();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/blindbox/retag", () => {
|
describe("POST /api/blindbox/retag", () => {
|
||||||
@@ -36,7 +43,7 @@ describe("POST /api/blindbox/retag", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/retag", {
|
const req = createRequest("/api/blindbox/retag", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
body: { roomId: "bb-room-1" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -51,7 +58,7 @@ describe("POST /api/blindbox/retag", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/retag", {
|
const req = createRequest("/api/blindbox/retag", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1", userId: "user-1" },
|
body: { roomId: "bb-room-1" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -60,7 +67,8 @@ describe("POST /api/blindbox/retag", () => {
|
|||||||
expect(data.retagged).toBe(0);
|
expect(data.retagged).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = createRequest("/api/blindbox/retag", {
|
const req = createRequest("/api/blindbox/retag", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1" },
|
body: { roomId: "bb-room-1" },
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
|||||||
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_BLINDBOX_ROOM, TEST_USER } from "@/__tests__/helpers/fixtures";
|
import { TEST_BLINDBOX_ROOM, TEST_USER } from "@/__tests__/helpers/fixtures";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/blindbox", () => ({
|
vi.mock("@/lib/blindbox", () => ({
|
||||||
getRoomByCode: vi.fn(),
|
getRoomByCode: vi.fn(),
|
||||||
requireMembership: vi.fn().mockResolvedValue({}),
|
requireMembership: vi.fn().mockResolvedValue({}),
|
||||||
@@ -10,12 +14,14 @@ vi.mock("@/lib/blindbox", () => ({
|
|||||||
|
|
||||||
import { GET, PATCH, DELETE } from "./route";
|
import { GET, PATCH, DELETE } from "./route";
|
||||||
import { getRoomByCode } from "@/lib/blindbox";
|
import { getRoomByCode } from "@/lib/blindbox";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const mockGetRoomByCode = vi.mocked(getRoomByCode);
|
const mockGetRoomByCode = vi.mocked(getRoomByCode);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPrismaMock();
|
resetPrismaMock();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/blindbox/room/[code]", () => {
|
describe("GET /api/blindbox/room/[code]", () => {
|
||||||
@@ -61,7 +67,7 @@ describe("PATCH /api/blindbox/room/[code]", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
const req = createRequest("/api/blindbox/room/ABC123", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { userId: "user-1", city: "上海", lat: 31.2, lng: 121.4 },
|
body: { city: "上海", lat: 31.2, lng: 121.4 },
|
||||||
});
|
});
|
||||||
const ctx = createRouteContext({ code: "ABC123" });
|
const ctx = createRouteContext({ code: "ABC123" });
|
||||||
const res = await PATCH(req, ctx);
|
const res = await PATCH(req, ctx);
|
||||||
@@ -76,7 +82,7 @@ describe("PATCH /api/blindbox/room/[code]", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
const req = createRequest("/api/blindbox/room/ABC123", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { userId: "user-1", lat: 999, lng: 121.4 },
|
body: { lat: 999, lng: 121.4 },
|
||||||
});
|
});
|
||||||
const ctx = createRouteContext({ code: "ABC123" });
|
const ctx = createRouteContext({ code: "ABC123" });
|
||||||
const res = await PATCH(req, ctx);
|
const res = await PATCH(req, ctx);
|
||||||
@@ -86,13 +92,11 @@ describe("PATCH /api/blindbox/room/[code]", () => {
|
|||||||
|
|
||||||
describe("DELETE /api/blindbox/room/[code]", () => {
|
describe("DELETE /api/blindbox/room/[code]", () => {
|
||||||
it("deletes room when creator", async () => {
|
it("deletes room when creator", async () => {
|
||||||
|
// user-1 is creator (TEST_BLINDBOX_ROOM.creatorId = "user-1")
|
||||||
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
|
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
|
||||||
prismaMock.blindBoxRoom.delete.mockResolvedValue({} as never);
|
prismaMock.blindBoxRoom.delete.mockResolvedValue({} as never);
|
||||||
|
|
||||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
|
||||||
method: "DELETE",
|
|
||||||
body: { userId: "user-1" },
|
|
||||||
});
|
|
||||||
const ctx = createRouteContext({ code: "ABC123" });
|
const ctx = createRouteContext({ code: "ABC123" });
|
||||||
const res = await DELETE(req, ctx);
|
const res = await DELETE(req, ctx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
@@ -101,14 +105,13 @@ describe("DELETE /api/blindbox/room/[code]", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("leaves room when not creator", async () => {
|
it("leaves room when not creator", async () => {
|
||||||
|
// Authenticate as user-2 (not creator)
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValueOnce("user-2");
|
||||||
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
|
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
|
||||||
prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-2" } as never);
|
prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-2" } as never);
|
||||||
prismaMock.blindBoxMember.delete.mockResolvedValue({} as never);
|
prismaMock.blindBoxMember.delete.mockResolvedValue({} as never);
|
||||||
|
|
||||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
|
||||||
method: "DELETE",
|
|
||||||
body: { userId: "user-2" },
|
|
||||||
});
|
|
||||||
const ctx = createRouteContext({ code: "ABC123" });
|
const ctx = createRouteContext({ code: "ABC123" });
|
||||||
const res = await DELETE(req, ctx);
|
const res = await DELETE(req, ctx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
@@ -117,13 +120,12 @@ describe("DELETE /api/blindbox/room/[code]", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 403 when not a member and not creator", async () => {
|
it("returns 403 when not a member and not creator", async () => {
|
||||||
|
// Authenticate as stranger (not creator, not member)
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValueOnce("stranger");
|
||||||
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
|
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
|
||||||
prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never);
|
prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never);
|
||||||
|
|
||||||
const req = createRequest("/api/blindbox/room/ABC123", {
|
const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
|
||||||
method: "DELETE",
|
|
||||||
body: { userId: "stranger" },
|
|
||||||
});
|
|
||||||
const ctx = createRouteContext({ code: "ABC123" });
|
const ctx = createRouteContext({ code: "ABC123" });
|
||||||
const res = await DELETE(req, ctx);
|
const res = await DELETE(req, ctx);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
|||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
|
import { TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
@@ -19,7 +23,7 @@ describe("POST /api/blindbox/room/join", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/room/join", {
|
const req = createRequest("/api/blindbox/room/join", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-2", code: "ABC123" },
|
body: { code: "ABC123" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -34,7 +38,7 @@ describe("POST /api/blindbox/room/join", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/room/join", {
|
const req = createRequest("/api/blindbox/room/join", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1", code: "ABC123" },
|
body: { code: "ABC123" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
@@ -47,7 +51,7 @@ describe("POST /api/blindbox/room/join", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/room/join", {
|
const req = createRequest("/api/blindbox/room/join", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1", code: "BADCODE" },
|
body: { code: "BADCODE" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
@@ -56,7 +60,7 @@ describe("POST /api/blindbox/room/join", () => {
|
|||||||
it("returns 400 when code is missing", async () => {
|
it("returns 400 when code is missing", async () => {
|
||||||
const req = createRequest("/api/blindbox/room/join", {
|
const req = createRequest("/api/blindbox/room/join", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1" },
|
body: {},
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
|
|||||||
@@ -2,17 +2,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_USER, TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
|
import { TEST_USER, TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/blindbox", () => ({
|
vi.mock("@/lib/blindbox", () => ({
|
||||||
generateUniqueRoomCode: vi.fn().mockResolvedValue("XYZ789"),
|
generateUniqueRoomCode: vi.fn().mockResolvedValue("XYZ789"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPrismaMock();
|
resetPrismaMock();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/blindbox/room", () => {
|
describe("POST /api/blindbox/room", () => {
|
||||||
@@ -25,7 +32,7 @@ describe("POST /api/blindbox/room", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/room", {
|
const req = createRequest("/api/blindbox/room", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1", name: "周末计划" },
|
body: { name: "周末计划" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -40,13 +47,14 @@ describe("POST /api/blindbox/room", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox/room", {
|
const req = createRequest("/api/blindbox/room", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1" },
|
body: {},
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = createRequest("/api/blindbox/room", {
|
const req = createRequest("/api/blindbox/room", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { name: "test" },
|
body: { name: "test" },
|
||||||
@@ -58,7 +66,7 @@ describe("POST /api/blindbox/room", () => {
|
|||||||
it("returns 400 when room name too long", async () => {
|
it("returns 400 when room name too long", async () => {
|
||||||
const req = createRequest("/api/blindbox/room", {
|
const req = createRequest("/api/blindbox/room", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1", name: "a".repeat(31) },
|
body: { name: "a".repeat(31) },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
import { GET } from "./route";
|
import { GET } from "./route";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPrismaMock();
|
resetPrismaMock();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/blindbox/rooms", () => {
|
describe("GET /api/blindbox/rooms", () => {
|
||||||
@@ -33,7 +40,7 @@ describe("GET /api/blindbox/rooms", () => {
|
|||||||
{ roomId: "bb-room-1", _count: 3 },
|
{ roomId: "bb-room-1", _count: 3 },
|
||||||
] as never);
|
] as never);
|
||||||
|
|
||||||
const req = createRequest("/api/blindbox/rooms?userId=user-1");
|
const req = createRequest("/api/blindbox/rooms");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
|
|
||||||
@@ -43,7 +50,8 @@ describe("GET /api/blindbox/rooms", () => {
|
|||||||
expect(data.rooms[0].poolCount).toBe(3);
|
expect(data.rooms[0].poolCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = createRequest("/api/blindbox/rooms");
|
const req = createRequest("/api/blindbox/rooms");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
|
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/blindbox", () => ({
|
vi.mock("@/lib/blindbox", () => ({
|
||||||
requireMembership: vi.fn().mockResolvedValue({}),
|
requireMembership: vi.fn().mockResolvedValue({}),
|
||||||
@@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST, GET, PUT, DELETE } from "./route";
|
import { POST, GET, PUT, DELETE } from "./route";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPrismaMock();
|
resetPrismaMock();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/blindbox (create idea)", () => {
|
describe("POST /api/blindbox (create idea)", () => {
|
||||||
@@ -35,7 +42,7 @@ describe("POST /api/blindbox (create idea)", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox", {
|
const req = createRequest("/api/blindbox", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1", userId: "user-1", content: "去公园野餐" },
|
body: { roomId: "bb-room-1", content: "去公园野餐" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -44,7 +51,8 @@ describe("POST /api/blindbox (create idea)", () => {
|
|||||||
expect(data.id).toBe("idea-1");
|
expect(data.id).toBe("idea-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = createRequest("/api/blindbox", {
|
const req = createRequest("/api/blindbox", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1", content: "test" },
|
body: { roomId: "bb-room-1", content: "test" },
|
||||||
@@ -56,7 +64,7 @@ describe("POST /api/blindbox (create idea)", () => {
|
|||||||
it("returns 400 when content is empty", async () => {
|
it("returns 400 when content is empty", async () => {
|
||||||
const req = createRequest("/api/blindbox", {
|
const req = createRequest("/api/blindbox", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1", userId: "user-1", content: "" },
|
body: { roomId: "bb-room-1", content: "" },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -65,7 +73,7 @@ describe("POST /api/blindbox (create idea)", () => {
|
|||||||
it("returns 400 when content over 200 chars", async () => {
|
it("returns 400 when content over 200 chars", async () => {
|
||||||
const req = createRequest("/api/blindbox", {
|
const req = createRequest("/api/blindbox", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "bb-room-1", userId: "user-1", content: "a".repeat(201) },
|
body: { roomId: "bb-room-1", content: "a".repeat(201) },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -79,7 +87,7 @@ describe("GET /api/blindbox (get pool data)", () => {
|
|||||||
.mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never)
|
.mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never)
|
||||||
.mockResolvedValueOnce([] as never);
|
.mockResolvedValueOnce([] as never);
|
||||||
|
|
||||||
const req = createRequest("/api/blindbox?userId=user-1&roomId=bb-room-1");
|
const req = createRequest("/api/blindbox?roomId=bb-room-1");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
|
|
||||||
@@ -89,7 +97,8 @@ describe("GET /api/blindbox (get pool data)", () => {
|
|||||||
expect(data.drawn).toHaveLength(0);
|
expect(data.drawn).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = createRequest("/api/blindbox?roomId=bb-room-1");
|
const req = createRequest("/api/blindbox?roomId=bb-room-1");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
@@ -102,7 +111,7 @@ describe("PUT /api/blindbox (edit idea)", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox", {
|
const req = createRequest("/api/blindbox", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { ideaId: "idea-1", userId: "user-1", content: "去公园散步" },
|
body: { ideaId: "idea-1", content: "去公园散步" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -116,7 +125,7 @@ describe("PUT /api/blindbox (edit idea)", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox", {
|
const req = createRequest("/api/blindbox", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { ideaId: "nonexistent", userId: "user-1", content: "test" },
|
body: { ideaId: "nonexistent", content: "test" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
@@ -129,7 +138,7 @@ describe("DELETE /api/blindbox (delete idea)", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox", {
|
const req = createRequest("/api/blindbox", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: { ideaId: "idea-1", userId: "user-1" },
|
body: { ideaId: "idea-1" },
|
||||||
});
|
});
|
||||||
const res = await DELETE(req, mockCtx);
|
const res = await DELETE(req, mockCtx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
@@ -142,7 +151,7 @@ describe("DELETE /api/blindbox (delete idea)", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/blindbox", {
|
const req = createRequest("/api/blindbox", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: { ideaId: "nonexistent", userId: "user-1" },
|
body: { ideaId: "nonexistent" },
|
||||||
});
|
});
|
||||||
const res = await DELETE(req, mockCtx);
|
const res = await DELETE(req, mockCtx);
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/blindbox", () => ({
|
vi.mock("@/lib/blindbox", () => ({
|
||||||
requireMembership: vi.fn().mockResolvedValue({}),
|
requireMembership: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||||
import { getRoomData } from "@/lib/store";
|
import { getRoomData } from "@/lib/roomRepository";
|
||||||
import { subscribe } from "@/lib/roomEvents";
|
import { subscribe } from "@/lib/roomEvents";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA } from "@/__tests__/helpers/fixtures";
|
|||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||||
|
|
||||||
vi.mock("@/lib/store", () => ({
|
vi.mock("@/lib/roomRepository", () => ({
|
||||||
atomicUpdateRoom: vi.fn(),
|
atomicUpdateRoom: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
|
|
||||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
|||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||||
|
|
||||||
vi.mock("@/lib/store", () => ({
|
vi.mock("@/lib/roomRepository", () => ({
|
||||||
atomicUpdateRoom: vi.fn(),
|
atomicUpdateRoom: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
|
|
||||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/
|
|||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||||
|
|
||||||
vi.mock("@/lib/store", () => ({
|
vi.mock("@/lib/roomRepository", () => ({
|
||||||
atomicUpdateRoom: vi.fn(),
|
atomicUpdateRoom: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
|
|
||||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
|||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||||
|
|
||||||
vi.mock("@/lib/store", () => ({
|
vi.mock("@/lib/roomRepository", () => ({
|
||||||
atomicUpdateRoom: vi.fn(),
|
atomicUpdateRoom: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
|
|
||||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
|||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||||
|
|
||||||
vi.mock("@/lib/store", () => ({
|
vi.mock("@/lib/roomRepository", () => ({
|
||||||
atomicUpdateRoom: vi.fn(),
|
atomicUpdateRoom: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
|
|
||||||
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { atomicUpdateRoom } from "@/lib/store";
|
import { atomicUpdateRoom } from "@/lib/roomRepository";
|
||||||
import { notify } from "@/lib/roomEvents";
|
import { notify } from "@/lib/roomEvents";
|
||||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ vi.mock("@/lib/prisma", () => ({
|
|||||||
prisma: {},
|
prisma: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/store", () => ({
|
vi.mock("@/lib/roomRepository", () => ({
|
||||||
createRoom: vi.fn().mockResolvedValue("ROOM01"),
|
createRoom: vi.fn().mockResolvedValue("ROOM01"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ const mockFetch = vi.fn();
|
|||||||
vi.stubGlobal("fetch", mockFetch);
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
import { createRoom } from "@/lib/store";
|
import { createRoom } from "@/lib/roomRepository";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createRoom } from "@/lib/store";
|
import { createRoom } from "@/lib/roomRepository";
|
||||||
import { Restaurant, SceneType } from "@/types";
|
import { Restaurant, SceneType } from "@/types";
|
||||||
import { getSceneConfig } from "@/lib/sceneConfig";
|
import { getSceneConfig } from "@/lib/sceneConfig";
|
||||||
import { apiHandler, ApiError } from "@/lib/api";
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
|
|||||||
@@ -2,17 +2,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
import { GET } from "./route";
|
import { GET } from "./route";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPrismaMock();
|
resetPrismaMock();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/user/achievements", () => {
|
describe("GET /api/user/achievements", () => {
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = createRequest("/api/user/achievements");
|
const req = createRequest("/api/user/achievements");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
@@ -48,7 +56,7 @@ describe("GET /api/user/achievements", () => {
|
|||||||
{ id: "bb-room-1", name: "周末", code: "ABC123" },
|
{ id: "bb-room-1", name: "周末", code: "ABC123" },
|
||||||
] as never);
|
] as never);
|
||||||
|
|
||||||
const req = createRequest("/api/user/achievements?userId=user-1");
|
const req = createRequest("/api/user/achievements");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
import { GET, POST, DELETE } from "./route";
|
import { GET, POST, DELETE } from "./route";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPrismaMock();
|
resetPrismaMock();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/user/favorite", () => {
|
describe("GET /api/user/favorite", () => {
|
||||||
it("returns empty array when no userId", async () => {
|
it("returns empty array when no favorites", async () => {
|
||||||
|
prismaMock.favorite.findMany.mockResolvedValue([] as never);
|
||||||
const req = createRequest("/api/user/favorite");
|
const req = createRequest("/api/user/favorite");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
@@ -30,7 +38,7 @@ describe("GET /api/user/favorite", () => {
|
|||||||
},
|
},
|
||||||
] as never);
|
] as never);
|
||||||
|
|
||||||
const req = createRequest("/api/user/favorite?userId=user-1");
|
const req = createRequest("/api/user/favorite");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
|
|
||||||
@@ -48,7 +56,7 @@ describe("POST /api/user/favorite", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user/favorite", {
|
const req = createRequest("/api/user/favorite", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1", restaurant: TEST_RESTAURANT },
|
body: { restaurant: TEST_RESTAURANT },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -70,7 +78,7 @@ describe("POST /api/user/favorite", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user/favorite", {
|
const req = createRequest("/api/user/favorite", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1", restaurant: TEST_RESTAURANT },
|
body: { restaurant: TEST_RESTAURANT },
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
@@ -79,7 +87,8 @@ describe("POST /api/user/favorite", () => {
|
|||||||
expect(data.id).toBe("fav-existing");
|
expect(data.id).toBe("fav-existing");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = createRequest("/api/user/favorite", {
|
const req = createRequest("/api/user/favorite", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { restaurant: TEST_RESTAURANT },
|
body: { restaurant: TEST_RESTAURANT },
|
||||||
@@ -91,7 +100,7 @@ describe("POST /api/user/favorite", () => {
|
|||||||
it("returns 400 when no restaurant", async () => {
|
it("returns 400 when no restaurant", async () => {
|
||||||
const req = createRequest("/api/user/favorite", {
|
const req = createRequest("/api/user/favorite", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1" },
|
body: {},
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -108,7 +117,7 @@ describe("DELETE /api/user/favorite", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user/favorite", {
|
const req = createRequest("/api/user/favorite", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: { userId: "user-1", favoriteId: "fav-1" },
|
body: { favoriteId: "fav-1" },
|
||||||
});
|
});
|
||||||
const res = await DELETE(req, mockCtx);
|
const res = await DELETE(req, mockCtx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
@@ -120,7 +129,7 @@ describe("DELETE /api/user/favorite", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user/favorite", {
|
const req = createRequest("/api/user/favorite", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: { userId: "user-1", favoriteId: "nonexistent" },
|
body: { favoriteId: "nonexistent" },
|
||||||
});
|
});
|
||||||
const res = await DELETE(req, mockCtx);
|
const res = await DELETE(req, mockCtx);
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
@@ -134,7 +143,7 @@ describe("DELETE /api/user/favorite", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user/favorite", {
|
const req = createRequest("/api/user/favorite", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: { userId: "user-1", favoriteId: "fav-1" },
|
body: { favoriteId: "fav-1" },
|
||||||
});
|
});
|
||||||
const res = await DELETE(req, mockCtx);
|
const res = await DELETE(req, mockCtx);
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
|
|||||||
@@ -2,17 +2,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
import { GET, POST } from "./route";
|
import { GET, POST } from "./route";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPrismaMock();
|
resetPrismaMock();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/user/history", () => {
|
describe("GET /api/user/history", () => {
|
||||||
it("returns empty array when no userId", async () => {
|
it("returns empty array when no history", async () => {
|
||||||
|
prismaMock.decision.findMany.mockResolvedValue([] as never);
|
||||||
const req = createRequest("/api/user/history");
|
const req = createRequest("/api/user/history");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
@@ -33,7 +41,7 @@ describe("GET /api/user/history", () => {
|
|||||||
},
|
},
|
||||||
] as never);
|
] as never);
|
||||||
|
|
||||||
const req = createRequest("/api/user/history?userId=user-1");
|
const req = createRequest("/api/user/history");
|
||||||
const res = await GET(req, mockCtx);
|
const res = await GET(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
|
|
||||||
@@ -53,7 +61,6 @@ describe("POST /api/user/history", () => {
|
|||||||
const req = createRequest("/api/user/history", {
|
const req = createRequest("/api/user/history", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
userId: "user-1",
|
|
||||||
roomId: "room-1",
|
roomId: "room-1",
|
||||||
restaurant: TEST_RESTAURANT,
|
restaurant: TEST_RESTAURANT,
|
||||||
matchType: "unanimous",
|
matchType: "unanimous",
|
||||||
@@ -74,7 +81,6 @@ describe("POST /api/user/history", () => {
|
|||||||
const req = createRequest("/api/user/history", {
|
const req = createRequest("/api/user/history", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
userId: "user-1",
|
|
||||||
roomId: "room-1",
|
roomId: "room-1",
|
||||||
restaurant: TEST_RESTAURANT,
|
restaurant: TEST_RESTAURANT,
|
||||||
matchType: "unanimous",
|
matchType: "unanimous",
|
||||||
@@ -97,7 +103,6 @@ describe("POST /api/user/history", () => {
|
|||||||
const req = createRequest("/api/user/history", {
|
const req = createRequest("/api/user/history", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
userId: "user-1",
|
|
||||||
roomId: "room-1",
|
roomId: "room-1",
|
||||||
restaurant: TEST_RESTAURANT,
|
restaurant: TEST_RESTAURANT,
|
||||||
matchType: "best",
|
matchType: "best",
|
||||||
@@ -111,13 +116,14 @@ describe("POST /api/user/history", () => {
|
|||||||
it("returns 400 when missing required fields", async () => {
|
it("returns 400 when missing required fields", async () => {
|
||||||
const req = createRequest("/api/user/history", {
|
const req = createRequest("/api/user/history", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { userId: "user-1" },
|
body: {},
|
||||||
});
|
});
|
||||||
const res = await POST(req, mockCtx);
|
const res = await POST(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = createRequest("/api/user/history", {
|
const req = createRequest("/api/user/history", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "best" },
|
body: { roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "best" },
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
import { TEST_USER } from "@/__tests__/helpers/fixtures";
|
import { TEST_USER } from "@/__tests__/helpers/fixtures";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("bcryptjs", () => ({
|
vi.mock("bcryptjs", () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -12,12 +17,14 @@ vi.mock("bcryptjs", () => ({
|
|||||||
|
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { GET, PUT } from "./route";
|
import { GET, PUT } from "./route";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const mockCtx = { params: Promise.resolve({}) };
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetPrismaMock();
|
resetPrismaMock();
|
||||||
vi.mocked(bcrypt.compare).mockReset();
|
vi.mocked(bcrypt.compare).mockReset();
|
||||||
|
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/user", () => {
|
describe("GET /api/user", () => {
|
||||||
@@ -65,7 +72,7 @@ describe("PUT /api/user", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user", {
|
const req = createRequest("/api/user", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { userId: "user-1", username: "newname" },
|
body: { username: "newname" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
const { status, data } = await parseJsonResponse(res);
|
const { status, data } = await parseJsonResponse(res);
|
||||||
@@ -81,7 +88,7 @@ describe("PUT /api/user", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user", {
|
const req = createRequest("/api/user", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { userId: "user-1", username: "takenname" },
|
body: { username: "takenname" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
@@ -94,7 +101,7 @@ describe("PUT /api/user", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user", {
|
const req = createRequest("/api/user", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { userId: "user-1", currentPassword: "old", newPassword: "newpass123" },
|
body: { currentPassword: "old", newPassword: "newpass123" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -106,7 +113,7 @@ describe("PUT /api/user", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user", {
|
const req = createRequest("/api/user", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { userId: "user-1", currentPassword: "wrong", newPassword: "newpass123" },
|
body: { currentPassword: "wrong", newPassword: "newpass123" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
@@ -117,13 +124,14 @@ describe("PUT /api/user", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user", {
|
const req = createRequest("/api/user", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { userId: "user-1", newPassword: "newpass123" },
|
body: { newPassword: "newpass123" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
|
||||||
const req = createRequest("/api/user", {
|
const req = createRequest("/api/user", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { username: "test" },
|
body: { username: "test" },
|
||||||
@@ -138,7 +146,7 @@ describe("PUT /api/user", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user", {
|
const req = createRequest("/api/user", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { userId: "user-1", avatar: "🦊" },
|
body: { avatar: "🦊" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
const { data } = await parseJsonResponse(res);
|
const { data } = await parseJsonResponse(res);
|
||||||
@@ -155,7 +163,7 @@ describe("PUT /api/user", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user", {
|
const req = createRequest("/api/user", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { userId: "user-1", email: "new@example.com" },
|
body: { email: "new@example.com" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -166,7 +174,7 @@ describe("PUT /api/user", () => {
|
|||||||
|
|
||||||
const req = createRequest("/api/user", {
|
const req = createRequest("/api/user", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { userId: "user-1", email: "notanemail" },
|
body: { email: "notanemail" },
|
||||||
});
|
});
|
||||||
const res = await PUT(req, mockCtx);
|
const res = await PUT(req, mockCtx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Mail,
|
||||||
|
Loader2,
|
||||||
|
LogOut,
|
||||||
|
Lock,
|
||||||
|
Edit3,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Zap,
|
||||||
|
Trophy,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Card from "@/components/Card";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { useFavorites } from "@/hooks/useFavorites";
|
||||||
|
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
||||||
|
import { setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
||||||
|
import { getAvatarBg, AVATARS } from "@/lib/avatars";
|
||||||
|
import type { UserProfile, UserPreferences } from "@/types";
|
||||||
|
|
||||||
|
export default function ProfilePage({ initialUserId }: { initialUserId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [userId] = useState(initialUserId);
|
||||||
|
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined);
|
||||||
|
|
||||||
|
const [editingUsername, setEditingUsername] = useState(false);
|
||||||
|
const [newUsername, setNewUsername] = useState("");
|
||||||
|
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||||
|
const [usernameMsg, setUsernameMsg] = useState("");
|
||||||
|
|
||||||
|
const [editingPassword, setEditingPassword] = useState(false);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||||
|
const [passwordMsg, setPasswordMsg] = useState("");
|
||||||
|
|
||||||
|
const [editingAvatar, setEditingAvatar] = useState(false);
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [emailSaving, setEmailSaving] = useState(false);
|
||||||
|
const [emailMsg, setEmailMsg] = useState("");
|
||||||
|
|
||||||
|
const [showFavorites, setShowFavorites] = useState(true);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/user?id=${userId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
setProfile(data);
|
||||||
|
setEmail(data.email ?? "");
|
||||||
|
setCachedProfile({ id: data.id, username: data.username, avatar: data.avatar });
|
||||||
|
if (data.preferences) setCachedPreferences(data.preferences);
|
||||||
|
} else {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("ProfilePage: fetch user failed:", e);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [userId, router]);
|
||||||
|
|
||||||
|
const handleSaveUsername = async () => {
|
||||||
|
const trimmed = newUsername.trim();
|
||||||
|
if (trimmed.length < 2 || trimmed.length > 16) {
|
||||||
|
setUsernameMsg("用户名需要 2-16 个字符");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsernameSaving(true);
|
||||||
|
setUsernameMsg("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/user", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, username: trimmed }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
|
||||||
|
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
|
||||||
|
setEditingUsername(false);
|
||||||
|
toast.show("用户名已更新");
|
||||||
|
} else {
|
||||||
|
setUsernameMsg(data.error ?? "更新失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUsernameMsg("网络错误");
|
||||||
|
} finally {
|
||||||
|
setUsernameSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePassword = async () => {
|
||||||
|
if (!currentPassword) {
|
||||||
|
setPasswordMsg("请输入当前密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setPasswordMsg("新密码至少 6 个字符");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordMsg("两次密码不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordSaving(true);
|
||||||
|
setPasswordMsg("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/user", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setEditingPassword(false);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
toast.show("密码已更新");
|
||||||
|
} else {
|
||||||
|
setPasswordMsg(data.error ?? "更新失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setPasswordMsg("网络错误");
|
||||||
|
} finally {
|
||||||
|
setPasswordSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAvatar = async (emoji: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/user", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, avatar: emoji }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
|
||||||
|
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
|
||||||
|
setEditingAvatar(false);
|
||||||
|
toast.show("头像已更新");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.show("更新失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEmail = async () => {
|
||||||
|
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
setEmailMsg("邮箱格式不正确");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmailSaving(true);
|
||||||
|
setEmailMsg("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/user", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, email: email || null }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEmailMsg(email ? "邮箱已绑定" : "邮箱已解绑");
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setEmailMsg(data.error ?? "保存失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setEmailMsg("网络错误");
|
||||||
|
} finally {
|
||||||
|
setEmailSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFavorite = async (favId: string) => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/user/favorite", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, favoriteId: favId }),
|
||||||
|
});
|
||||||
|
mutateFavorites((prev) => prev?.filter((x) => x.id !== favId), false);
|
||||||
|
toast.show("已取消收藏");
|
||||||
|
} catch {
|
||||||
|
toast.show("操作失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
|
||||||
|
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
|
||||||
|
<div className="h-8 w-8" />
|
||||||
|
<h1 className="flex-1 text-base font-bold text-heading">个人中心</h1>
|
||||||
|
</nav>
|
||||||
|
<div className="mx-auto max-w-sm px-5">
|
||||||
|
<ProfileCardSkeleton />
|
||||||
|
<Card className="mt-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="mt-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
<RecordItemSkeleton />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
|
||||||
|
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-muted transition-colors active:bg-elevated"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 className="flex-1 text-base font-bold text-heading">个人中心</h1>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-sm px-5">
|
||||||
|
{/* Profile card */}
|
||||||
|
<Card animated>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingAvatar(!editingAvatar)}
|
||||||
|
className={`relative flex h-14 w-14 items-center justify-center rounded-2xl text-2xl transition-transform active:scale-95 ${getAvatarBg(profile.avatar)}`}
|
||||||
|
>
|
||||||
|
{profile.avatar}
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-surface text-muted shadow-sm ring-1 ring-border">
|
||||||
|
<Edit3 size={10} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
{editingUsername ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewUsername(e.target.value.slice(0, 16));
|
||||||
|
setUsernameMsg("");
|
||||||
|
}}
|
||||||
|
maxLength={16}
|
||||||
|
autoFocus
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveUsername}
|
||||||
|
disabled={usernameSaving}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{usernameSaving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingUsername(false); setUsernameMsg(""); }}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-elevated text-muted"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-bold text-heading">{profile.username}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
|
||||||
|
className="text-muted transition-colors active:text-secondary"
|
||||||
|
>
|
||||||
|
<Edit3 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{usernameMsg && <p className="mt-1 text-xs text-rose-400">{usernameMsg}</p>}
|
||||||
|
{(profile.decisionCount ?? 0) > 0 && (
|
||||||
|
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
|
||||||
|
<Zap size={11} className="text-amber-400" />
|
||||||
|
已拯救 {profile.decisionCount} 次选择困难症
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar picker */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{editingAvatar && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="mt-4 grid grid-cols-6 gap-2">
|
||||||
|
{AVATARS.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.emoji}
|
||||||
|
onClick={() => handleSaveAvatar(a.emoji)}
|
||||||
|
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
|
||||||
|
profile.avatar === a.emoji
|
||||||
|
? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
|
||||||
|
: "bg-elevated hover:bg-subtle"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{a.emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Change password */}
|
||||||
|
<Card animated className="mt-4" delay={0.05}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
|
||||||
|
className="flex w-full items-center gap-2"
|
||||||
|
>
|
||||||
|
<Lock size={15} className="text-muted" />
|
||||||
|
<h3 className="text-sm font-semibold text-secondary">修改密码</h3>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{editingPassword && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted">当前密码</p>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
|
||||||
|
className="pr-9"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted">新密码</p>
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
|
||||||
|
placeholder="至少 6 个字符"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted">确认新密码</p>
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passwordMsg && (
|
||||||
|
<p className={`text-xs ${passwordMsg.includes("错误") || passwordMsg.includes("失败") || passwordMsg.includes("不一致") || passwordMsg.includes("至少") ? "text-rose-400" : "text-accent"}`}>
|
||||||
|
{passwordMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSavePassword}
|
||||||
|
disabled={passwordSaving}
|
||||||
|
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-accent text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{passwordSaving ? <Loader2 size={14} className="animate-spin" /> : "保存新密码"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Email binding */}
|
||||||
|
<Card animated className="mt-4" delay={0.1}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail size={15} className="text-muted" />
|
||||||
|
<h3 className="text-sm font-semibold text-secondary">绑定邮箱</h3>
|
||||||
|
<span className="text-[10px] text-dim">(可选)</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
setEmailMsg("");
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveEmail}
|
||||||
|
disabled={emailSaving}
|
||||||
|
className="flex h-9 items-center gap-1 rounded-lg bg-accent px-3 text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{emailSaving ? <Loader2 size={13} className="animate-spin" /> : "保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{emailMsg && (
|
||||||
|
<p className={`mt-2 text-xs ${emailMsg.includes("失败") || emailMsg.includes("不正确") ? "text-rose-400" : "text-accent"}`}>
|
||||||
|
{emailMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Achievements link */}
|
||||||
|
<Card animated className="mt-4" delay={0.15}>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/achievements")}
|
||||||
|
className="flex w-full items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trophy size={15} className="text-amber-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-secondary">成就墙</h3>
|
||||||
|
<span className="text-[10px] text-dim">决策记录 · 契约成就</span>
|
||||||
|
<ChevronRight size={14} className="ml-auto text-muted" />
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ProfileFavoritesCard
|
||||||
|
favorites={favorites}
|
||||||
|
loading={favLoading}
|
||||||
|
open={showFavorites}
|
||||||
|
onToggle={() => setShowFavorites((v) => !v)}
|
||||||
|
onRemove={handleRemoveFavorite}
|
||||||
|
onEmpty={() => router.push("/blindbox")}
|
||||||
|
delay={0.2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-6 flex justify-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.25 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center justify-center gap-2 rounded-xl bg-surface px-6 py-2.5 text-sm font-medium text-rose-400/80 ring-1 ring-border transition-colors hover:bg-elevated hover:text-rose-400"
|
||||||
|
>
|
||||||
|
<LogOut size={14} />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ vi.mock("@/components/ProfileFavoritesCard", () => ({
|
|||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
vi.stubGlobal("fetch", mockFetch);
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
|
||||||
import ProfilePage from "./page";
|
import ProfilePage from "./ProfileClient";
|
||||||
|
|
||||||
const toastCtx: ToastContextValue = { show: vi.fn() };
|
const toastCtx: ToastContextValue = { show: vi.fn() };
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ function renderPage() {
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
ToastContext.Provider,
|
ToastContext.Provider,
|
||||||
{ value: toastCtx },
|
{ value: toastCtx },
|
||||||
React.createElement(ProfilePage),
|
React.createElement(ProfilePage, { initialUserId: "user-1" }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-505
@@ -1,511 +1,20 @@
|
|||||||
"use client";
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { verifyToken } from "@/lib/auth";
|
||||||
|
import ProfileClient from "./ProfileClient";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
export default async function ProfilePage() {
|
||||||
import { useRouter } from "next/navigation";
|
const cookieStore = await cookies();
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
const token = cookieStore.get("nw_token")?.value;
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Mail,
|
|
||||||
Loader2,
|
|
||||||
LogOut,
|
|
||||||
Lock,
|
|
||||||
Edit3,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Zap,
|
|
||||||
Trophy,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Card from "@/components/Card";
|
|
||||||
import Input from "@/components/Input";
|
|
||||||
import ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
|
|
||||||
import { useToast } from "@/hooks/useToast";
|
|
||||||
import { useFavorites } from "@/hooks/useFavorites";
|
|
||||||
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
|
||||||
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
|
||||||
import { getAvatarBg, AVATARS } from "@/lib/avatars";
|
|
||||||
import type { UserProfile, UserPreferences } from "@/types";
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
if (!token) redirect("/");
|
||||||
const router = useRouter();
|
|
||||||
const [userId, setUserId] = useState("");
|
|
||||||
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined);
|
let userId: string;
|
||||||
|
try {
|
||||||
const [editingUsername, setEditingUsername] = useState(false);
|
userId = await verifyToken(token);
|
||||||
const [newUsername, setNewUsername] = useState("");
|
} catch {
|
||||||
const [usernameSaving, setUsernameSaving] = useState(false);
|
redirect("/");
|
||||||
const [usernameMsg, setUsernameMsg] = useState("");
|
|
||||||
|
|
||||||
const [editingPassword, setEditingPassword] = useState(false);
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
|
||||||
const [passwordMsg, setPasswordMsg] = useState("");
|
|
||||||
|
|
||||||
const [editingAvatar, setEditingAvatar] = useState(false);
|
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [emailSaving, setEmailSaving] = useState(false);
|
|
||||||
const [emailMsg, setEmailMsg] = useState("");
|
|
||||||
|
|
||||||
const [showFavorites, setShowFavorites] = useState(true);
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cached = getCachedProfile();
|
|
||||||
if (!cached) {
|
|
||||||
router.push("/");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = getUserId();
|
|
||||||
setUserId(id);
|
|
||||||
|
|
||||||
fetch(`/api/user?id=${id}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data) {
|
|
||||||
setProfile(data);
|
|
||||||
setEmail(data.email ?? "");
|
|
||||||
setCachedProfile({ id: data.id, username: data.username, avatar: data.avatar });
|
|
||||||
if (data.preferences) setCachedPreferences(data.preferences);
|
|
||||||
} else {
|
|
||||||
router.push("/");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("ProfilePage: fetch user failed:", e);
|
|
||||||
setProfile({ ...cached });
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const handleSaveUsername = async () => {
|
|
||||||
const trimmed = newUsername.trim();
|
|
||||||
if (trimmed.length < 2 || trimmed.length > 16) {
|
|
||||||
setUsernameMsg("用户名需要 2-16 个字符");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUsernameSaving(true);
|
|
||||||
setUsernameMsg("");
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/user", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ userId, username: trimmed }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
|
|
||||||
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
|
|
||||||
setEditingUsername(false);
|
|
||||||
toast.show("用户名已更新");
|
|
||||||
} else {
|
|
||||||
setUsernameMsg(data.error ?? "更新失败");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setUsernameMsg("网络错误");
|
|
||||||
} finally {
|
|
||||||
setUsernameSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSavePassword = async () => {
|
|
||||||
if (!currentPassword) {
|
|
||||||
setPasswordMsg("请输入当前密码");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newPassword.length < 6) {
|
|
||||||
setPasswordMsg("新密码至少 6 个字符");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
setPasswordMsg("两次密码不一致");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPasswordSaving(true);
|
|
||||||
setPasswordMsg("");
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/user", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ userId, currentPassword, newPassword }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
setEditingPassword(false);
|
|
||||||
setCurrentPassword("");
|
|
||||||
setNewPassword("");
|
|
||||||
setConfirmPassword("");
|
|
||||||
toast.show("密码已更新");
|
|
||||||
} else {
|
|
||||||
setPasswordMsg(data.error ?? "更新失败");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setPasswordMsg("网络错误");
|
|
||||||
} finally {
|
|
||||||
setPasswordSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAvatar = async (emoji: string) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/user", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ userId, avatar: emoji }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
|
|
||||||
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
|
|
||||||
setEditingAvatar(false);
|
|
||||||
toast.show("头像已更新");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.show("更新失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEmail = async () => {
|
|
||||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
setEmailMsg("邮箱格式不正确");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEmailSaving(true);
|
|
||||||
setEmailMsg("");
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/user", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ userId, email: email || null }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setEmailMsg(email ? "邮箱已绑定" : "邮箱已解绑");
|
|
||||||
} else {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
setEmailMsg(data.error ?? "保存失败");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setEmailMsg("网络错误");
|
|
||||||
} finally {
|
|
||||||
setEmailSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveFavorite = async (favId: string) => {
|
|
||||||
try {
|
|
||||||
await fetch("/api/user/favorite", {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ userId, favoriteId: favId }),
|
|
||||||
});
|
|
||||||
mutateFavorites((prev) => prev?.filter((x) => x.id !== favId), false);
|
|
||||||
toast.show("已取消收藏");
|
|
||||||
} catch {
|
|
||||||
toast.show("操作失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout();
|
|
||||||
router.push("/");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
|
|
||||||
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
|
|
||||||
<div className="h-8 w-8" />
|
|
||||||
<h1 className="flex-1 text-base font-bold text-heading">个人中心</h1>
|
|
||||||
</nav>
|
|
||||||
<div className="mx-auto max-w-sm px-5">
|
|
||||||
<ProfileCardSkeleton />
|
|
||||||
<Card className="mt-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card className="mt-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
<RecordItemSkeleton />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!profile) return null;
|
return <ProfileClient initialUserId={userId} />;
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
|
|
||||||
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full text-muted transition-colors active:bg-elevated"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
</button>
|
|
||||||
<h1 className="flex-1 text-base font-bold text-heading">个人中心</h1>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="mx-auto max-w-sm px-5">
|
|
||||||
{/* Profile card */}
|
|
||||||
<Card animated>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingAvatar(!editingAvatar)}
|
|
||||||
className={`relative flex h-14 w-14 items-center justify-center rounded-2xl text-2xl transition-transform active:scale-95 ${getAvatarBg(profile.avatar)}`}
|
|
||||||
>
|
|
||||||
{profile.avatar}
|
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-surface text-muted shadow-sm ring-1 ring-border">
|
|
||||||
<Edit3 size={10} />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<div className="flex-1">
|
|
||||||
{editingUsername ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={newUsername}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNewUsername(e.target.value.slice(0, 16));
|
|
||||||
setUsernameMsg("");
|
|
||||||
}}
|
|
||||||
maxLength={16}
|
|
||||||
autoFocus
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveUsername}
|
|
||||||
disabled={usernameSaving}
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-white disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{usernameSaving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setEditingUsername(false); setUsernameMsg(""); }}
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-elevated text-muted"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h2 className="text-lg font-bold text-heading">{profile.username}</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
|
|
||||||
className="text-muted transition-colors active:text-secondary"
|
|
||||||
>
|
|
||||||
<Edit3 size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{usernameMsg && <p className="mt-1 text-xs text-rose-400">{usernameMsg}</p>}
|
|
||||||
{(profile.decisionCount ?? 0) > 0 && (
|
|
||||||
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
|
|
||||||
<Zap size={11} className="text-amber-400" />
|
|
||||||
已拯救 {profile.decisionCount} 次选择困难症
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Avatar picker */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{editingAvatar && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: "auto", opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="mt-4 grid grid-cols-6 gap-2">
|
|
||||||
{AVATARS.map((a) => (
|
|
||||||
<button
|
|
||||||
key={a.emoji}
|
|
||||||
onClick={() => handleSaveAvatar(a.emoji)}
|
|
||||||
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
|
|
||||||
profile.avatar === a.emoji
|
|
||||||
? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
|
|
||||||
: "bg-elevated hover:bg-subtle"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{a.emoji}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Change password */}
|
|
||||||
<Card animated className="mt-4" delay={0.05}>
|
|
||||||
<button
|
|
||||||
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
|
|
||||||
className="flex w-full items-center gap-2"
|
|
||||||
>
|
|
||||||
<Lock size={15} className="text-muted" />
|
|
||||||
<h3 className="text-sm font-semibold text-secondary">修改密码</h3>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{editingPassword && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: "auto", opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="mt-3 flex flex-col gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted">当前密码</p>
|
|
||||||
<div className="relative mt-1">
|
|
||||||
<Input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
|
|
||||||
className="pr-9"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted">新密码</p>
|
|
||||||
<Input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
|
|
||||||
placeholder="至少 6 个字符"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted">确认新密码</p>
|
|
||||||
<Input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
|
|
||||||
placeholder="再次输入新密码"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{passwordMsg && (
|
|
||||||
<p className={`text-xs ${passwordMsg.includes("错误") || passwordMsg.includes("失败") || passwordMsg.includes("不一致") || passwordMsg.includes("至少") ? "text-rose-400" : "text-accent"}`}>
|
|
||||||
{passwordMsg}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSavePassword}
|
|
||||||
disabled={passwordSaving}
|
|
||||||
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-accent text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{passwordSaving ? <Loader2 size={14} className="animate-spin" /> : "保存新密码"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Email binding */}
|
|
||||||
<Card animated className="mt-4" delay={0.1}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Mail size={15} className="text-muted" />
|
|
||||||
<h3 className="text-sm font-semibold text-secondary">绑定邮箱</h3>
|
|
||||||
<span className="text-[10px] text-dim">(可选)</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEmail(e.target.value);
|
|
||||||
setEmailMsg("");
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveEmail}
|
|
||||||
disabled={emailSaving}
|
|
||||||
className="flex h-9 items-center gap-1 rounded-lg bg-accent px-3 text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{emailSaving ? <Loader2 size={13} className="animate-spin" /> : "保存"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{emailMsg && (
|
|
||||||
<p className={`mt-2 text-xs ${emailMsg.includes("失败") || emailMsg.includes("不正确") ? "text-rose-400" : "text-accent"}`}>
|
|
||||||
{emailMsg}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Achievements link */}
|
|
||||||
<Card animated className="mt-4" delay={0.15}>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push("/achievements")}
|
|
||||||
className="flex w-full items-center gap-2"
|
|
||||||
>
|
|
||||||
<Trophy size={15} className="text-amber-400" />
|
|
||||||
<h3 className="text-sm font-semibold text-secondary">成就墙</h3>
|
|
||||||
<span className="text-[10px] text-dim">决策记录 · 契约成就</span>
|
|
||||||
<ChevronRight size={14} className="ml-auto text-muted" />
|
|
||||||
</button>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ProfileFavoritesCard
|
|
||||||
favorites={favorites}
|
|
||||||
loading={favLoading}
|
|
||||||
open={showFavorites}
|
|
||||||
onToggle={() => setShowFavorites((v) => !v)}
|
|
||||||
onRemove={handleRemoveFavorite}
|
|
||||||
onEmpty={() => router.push("/blindbox")}
|
|
||||||
delay={0.2}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<motion.div
|
|
||||||
className="mt-6 flex justify-center"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.25 }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="flex items-center justify-center gap-2 rounded-xl bg-surface px-6 py-2.5 text-sm font-medium text-rose-400/80 ring-1 ring-border transition-colors hover:bg-elevated hover:text-rose-400"
|
|
||||||
>
|
|
||||||
<LogOut size={14} />
|
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,21 +121,24 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose}>
|
<Modal open={open} onClose={onClose} titleId="auth-modal-title">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<span className="text-lg font-bold text-heading">欢迎</span>
|
<span id="auth-modal-title" className="text-lg font-bold text-heading">欢迎</span>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label="关闭"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 rounded-xl bg-elevated p-1">
|
<div role="tablist" className="flex gap-1 rounded-xl bg-elevated p-1">
|
||||||
{(["login", "register"] as const).map((t) => (
|
{(["login", "register"] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === t}
|
||||||
onClick={() => switchTab(t)}
|
onClick={() => switchTab(t)}
|
||||||
className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
|
className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
|
||||||
tab === t ? "text-heading" : "text-muted"
|
tab === t ? "text-heading" : "text-muted"
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ export default function BlindboxPlan({
|
|||||||
<button
|
<button
|
||||||
key={day.date}
|
key={day.date}
|
||||||
onClick={() => setDayIndex(i)}
|
onClick={() => setDayIndex(i)}
|
||||||
|
aria-label={day.date}
|
||||||
|
aria-pressed={i === dayIndex}
|
||||||
className={`rounded-full transition-all ${
|
className={`rounded-full transition-all ${
|
||||||
i === dayIndex
|
i === dayIndex
|
||||||
? "h-1.5 w-5 bg-purple-400"
|
? "h-1.5 w-5 bg-purple-400"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
variant?: ModalVariant;
|
variant?: ModalVariant;
|
||||||
|
/** Used for aria-labelledby; the element with this id should contain the dialog title */
|
||||||
|
titleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheet = {
|
const sheet = {
|
||||||
@@ -41,6 +43,7 @@ export default function Modal({
|
|||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
variant = "sheet",
|
variant = "sheet",
|
||||||
|
titleId,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
const v = variants[variant];
|
const v = variants[variant];
|
||||||
@@ -60,6 +63,9 @@ export default function Modal({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
className={v.content}
|
className={v.content}
|
||||||
initial={v.initial}
|
initial={v.initial}
|
||||||
animate={v.animate}
|
animate={v.animate}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function PlanItemEditModal({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-bold text-heading">编辑活动</h3>
|
<h3 className="text-sm font-bold text-heading">编辑活动</h3>
|
||||||
<button onClick={handleClose} className="text-muted active:text-foreground">
|
<button onClick={handleClose} aria-label="关闭" className="text-muted active:text-foreground">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default function QrInviteModal({
|
|||||||
<Modal open={open} onClose={onClose}>
|
<Modal open={open} onClose={onClose}>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label="关闭"
|
||||||
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
|
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
|
|||||||
+22
-10
@@ -5,6 +5,11 @@ import { useEffect, useRef } from "react";
|
|||||||
import { fetcher, FetchError } from "@/lib/fetcher";
|
import { fetcher, FetchError } from "@/lib/fetcher";
|
||||||
import { RoomStatus } from "@/types";
|
import { RoomStatus } from "@/types";
|
||||||
|
|
||||||
|
// Exponential backoff with jitter: min(2000 * 2^attempt + rand(500), 60s)
|
||||||
|
function backoffMs(attempt: number): number {
|
||||||
|
return Math.min(2000 * Math.pow(2, attempt) + Math.random() * 500, 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
export function useRoomPolling(roomId: string | undefined) {
|
export function useRoomPolling(roomId: string | undefined) {
|
||||||
const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
|
const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
|
||||||
roomId ? `/api/room/${roomId}` : null,
|
roomId ? `/api/room/${roomId}` : null,
|
||||||
@@ -18,13 +23,21 @@ export function useRoomPolling(roomId: string | undefined) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const fallbackRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const fallbackRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const attemptRef = useRef(0);
|
||||||
const notFound = error?.message === "NOT_FOUND";
|
const notFound = error?.message === "NOT_FOUND";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
const es = new EventSource(`/api/room/${roomId}/events`);
|
const es = new EventSource(`/api/room/${roomId}/events`);
|
||||||
|
|
||||||
|
const clearFallback = () => {
|
||||||
|
if (fallbackRef.current) {
|
||||||
|
clearTimeout(fallbackRef.current);
|
||||||
|
fallbackRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(e.data);
|
const parsed = JSON.parse(e.data);
|
||||||
@@ -38,23 +51,22 @@ export function useRoomPolling(roomId: string | undefined) {
|
|||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
if (!fallbackRef.current) {
|
if (!fallbackRef.current) {
|
||||||
fallbackRef.current = setInterval(() => mutate(), 2000);
|
const delay = backoffMs(attemptRef.current++);
|
||||||
|
fallbackRef.current = setTimeout(() => {
|
||||||
|
mutate();
|
||||||
|
fallbackRef.current = null;
|
||||||
|
}, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
if (fallbackRef.current) {
|
attemptRef.current = 0;
|
||||||
clearInterval(fallbackRef.current);
|
clearFallback();
|
||||||
fallbackRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
es.close();
|
es.close();
|
||||||
if (fallbackRef.current) {
|
clearFallback();
|
||||||
clearInterval(fallbackRef.current);
|
|
||||||
fallbackRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [roomId, mutate]);
|
}, [roomId, mutate]);
|
||||||
|
|
||||||
|
|||||||
+30
-74
@@ -4,6 +4,13 @@ import type {
|
|||||||
ChatCompletionTool,
|
ChatCompletionTool,
|
||||||
} from "openai/resources/chat/completions";
|
} from "openai/resources/chat/completions";
|
||||||
import type { IdeaTags, PlanItem } from "@/types";
|
import type { IdeaTags, PlanItem } from "@/types";
|
||||||
|
import {
|
||||||
|
IdeaTagsSchema,
|
||||||
|
SuggestIdeasSchema,
|
||||||
|
GenerateScheduleSchema,
|
||||||
|
RefinePlanSchema,
|
||||||
|
SuggestAlternativesSchema,
|
||||||
|
} from "@/lib/schemas/ai";
|
||||||
|
|
||||||
function getClient() {
|
function getClient() {
|
||||||
const apiKey = process.env.DEEPSEEK_API_KEY;
|
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||||||
@@ -101,37 +108,10 @@ export async function tagIdea(content: string): Promise<IdeaTags | null> {
|
|||||||
const text = response.choices[0]?.message?.content;
|
const text = response.choices[0]?.message?.content;
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
const parsed = JSON.parse(text);
|
const result = IdeaTagsSchema.safeParse(JSON.parse(text));
|
||||||
|
if (!result.success) return null;
|
||||||
|
|
||||||
const validCategories = ["dining", "outdoor", "entertainment", "shopping", "sports", "culture", "relaxation", "experience", "nature"];
|
return result.data as IdeaTags;
|
||||||
const validTimeSlots = ["morning", "afternoon", "evening", "flexible", "all_day"];
|
|
||||||
const validSearchTypes = ["brand", "place", "category"];
|
|
||||||
const validCostLevels = ["free", "budget", "moderate", "premium"];
|
|
||||||
const validIntensities = ["chill", "moderate", "active"];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!validCategories.includes(parsed.category) ||
|
|
||||||
!validTimeSlots.includes(parsed.timeSlot) ||
|
|
||||||
!validSearchTypes.includes(parsed.searchType) ||
|
|
||||||
typeof parsed.estimatedMinutes !== "number" ||
|
|
||||||
!validCostLevels.includes(parsed.costLevel) ||
|
|
||||||
!validIntensities.includes(parsed.intensity) ||
|
|
||||||
typeof parsed.needsBooking !== "boolean" ||
|
|
||||||
typeof parsed.searchQuery !== "string"
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
category: parsed.category,
|
|
||||||
timeSlot: parsed.timeSlot,
|
|
||||||
estimatedMinutes: parsed.estimatedMinutes,
|
|
||||||
costLevel: parsed.costLevel,
|
|
||||||
intensity: parsed.intensity,
|
|
||||||
needsBooking: parsed.needsBooking,
|
|
||||||
searchQuery: parsed.searchQuery,
|
|
||||||
searchType: parsed.searchType,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("tagIdea failed:", e);
|
console.error("tagIdea failed:", e);
|
||||||
return null;
|
return null;
|
||||||
@@ -165,11 +145,11 @@ export async function suggestIdeas(existingIdeas: string[]): Promise<string[]> {
|
|||||||
const text = response.choices[0]?.message?.content;
|
const text = response.choices[0]?.message?.content;
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
|
|
||||||
const parsed = JSON.parse(text);
|
const result = SuggestIdeasSchema.safeParse(JSON.parse(text));
|
||||||
if (!Array.isArray(parsed.suggestions)) return [];
|
if (!result.success) return [];
|
||||||
|
|
||||||
return parsed.suggestions
|
return result.data.suggestions
|
||||||
.filter((s: unknown) => typeof s === "string" && s.length > 0)
|
.filter((s) => s.length > 0)
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("suggestIdeas failed:", e);
|
console.error("suggestIdeas failed:", e);
|
||||||
@@ -214,29 +194,17 @@ ${Object.entries(ctx.candidates)
|
|||||||
const text = response.choices[0]?.message?.content;
|
const text = response.choices[0]?.message?.content;
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
const parsed = JSON.parse(text);
|
const result = GenerateScheduleSchema.safeParse(JSON.parse(text));
|
||||||
if (!Array.isArray(parsed.items) || parsed.items.length === 0) return null;
|
if (!result.success) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: parsed.items.map((item: Record<string, unknown>) => ({
|
items: result.data.items.map((item) => ({
|
||||||
time: String(item.time ?? ""),
|
...item,
|
||||||
activity: String(item.activity ?? ""),
|
...(item.transitToNext != null && item.transitToNext > 0
|
||||||
poi: String(item.poi ?? ""),
|
? { transitToNext: Math.round(item.transitToNext) }
|
||||||
address: String(item.address ?? ""),
|
: { transitToNext: undefined, transitDescription: undefined }),
|
||||||
lat: Number(item.lat) || 0,
|
})) as PlanItem[],
|
||||||
lng: Number(item.lng) || 0,
|
summary: result.data.summary,
|
||||||
duration: Number(item.duration) || 60,
|
|
||||||
reason: String(item.reason ?? ""),
|
|
||||||
...(item.transitToNext != null && Number(item.transitToNext) > 0
|
|
||||||
? {
|
|
||||||
transitToNext: Math.round(Number(item.transitToNext)),
|
|
||||||
...(item.transitDescription
|
|
||||||
? { transitDescription: String(item.transitDescription) }
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
})),
|
|
||||||
summary: String(parsed.summary ?? ""),
|
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("generateSchedule failed:", e);
|
console.error("generateSchedule failed:", e);
|
||||||
@@ -278,15 +246,11 @@ export async function refinePlan(
|
|||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text);
|
||||||
const result = parsed.days ?? parsed;
|
const rawResult = parsed.days ?? parsed;
|
||||||
if (!Array.isArray(result) || result.length === 0) return null;
|
const result = RefinePlanSchema.safeParse({ days: rawResult });
|
||||||
if (!result.every((d: unknown) => {
|
if (!result.success) return null;
|
||||||
if (typeof d !== "object" || d === null) return false;
|
|
||||||
const day = d as Record<string, unknown>;
|
|
||||||
return typeof day.date === "string" && Array.isArray(day.items);
|
|
||||||
})) return null;
|
|
||||||
|
|
||||||
return result as import("@/types").WeekendPlanData[];
|
return result.data.days as unknown as import("@/types").WeekendPlanData[];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("refinePlan failed:", e);
|
console.error("refinePlan failed:", e);
|
||||||
return null;
|
return null;
|
||||||
@@ -329,18 +293,10 @@ export async function suggestAlternativeItems(
|
|||||||
const text = response.choices[0]?.message?.content;
|
const text = response.choices[0]?.message?.content;
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
const parsed = JSON.parse(text);
|
const result = SuggestAlternativesSchema.safeParse(JSON.parse(text));
|
||||||
if (!Array.isArray(parsed.alternatives)) return null;
|
if (!result.success) return null;
|
||||||
|
|
||||||
return parsed.alternatives
|
return result.data.alternatives.slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>;
|
||||||
.filter(
|
|
||||||
(a: unknown) =>
|
|
||||||
typeof a === "object" &&
|
|
||||||
a !== null &&
|
|
||||||
typeof (a as Record<string, unknown>).activity === "string" &&
|
|
||||||
typeof (a as Record<string, unknown>).searchQuery === "string",
|
|
||||||
)
|
|
||||||
.slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("suggestAlternativeItems failed:", e);
|
console.error("suggestAlternativeItems failed:", e);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ describe("apiHandler", () => {
|
|||||||
const res = await handler(req, mockCtx);
|
const res = await handler(req, mockCtx);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(data.error).toBe("操作失败");
|
expect(data.error).toBe("操作失败 [Error: unexpected]");
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-710
@@ -1,710 +1,4 @@
|
|||||||
/**
|
// Re-export from refactored modules for backward compatibility
|
||||||
* Shared plan generation logic for blindbox weekend plans.
|
export type { PlanGenAvailableTime, PlanGenResult } from "./plan/index";
|
||||||
* Supports optional progress callback for streaming UX.
|
export { runPlanGeneration } from "./plan/index";
|
||||||
*
|
export { PLAN_PROGRESS_MESSAGES } from "./plan/legacyPlan";
|
||||||
* Primary path: tool-calling agent (runAgentPlanGeneration)
|
|
||||||
* Fallback path: legacy pipeline (runLegacyPlanGeneration)
|
|
||||||
*/
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { searchPois, getTransitDirection } from "@/lib/amap";
|
|
||||||
import { generateSchedule, runAgentLoop, type ScheduleContext, type AgentTool } 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;
|
|
||||||
costLevel: string | null;
|
|
||||||
intensity: string | null;
|
|
||||||
needsBooking: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Slot-based idea selection (used by legacy path)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const SLOT_CATEGORY_MAP: Record<string, string[]> = {
|
|
||||||
morning: ["outdoor", "nature", "sports", "culture"],
|
|
||||||
lunch: ["dining"],
|
|
||||||
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture", "experience"],
|
|
||||||
dinner: ["dining"],
|
|
||||||
evening: ["entertainment", "relaxation", "experience"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const SLOT_TIME_MAP: Record<string, string[]> = {
|
|
||||||
morning: ["morning", "flexible"],
|
|
||||||
lunch: ["flexible"],
|
|
||||||
afternoon: ["afternoon", "flexible"],
|
|
||||||
dinner: ["evening", "flexible"],
|
|
||||||
evening: ["evening", "flexible"],
|
|
||||||
};
|
|
||||||
|
|
||||||
function matchesSlot(idea: TaggedIdea, slot: string): boolean {
|
|
||||||
if (idea.timeSlot === "all_day") return true;
|
|
||||||
const validTimes = SLOT_TIME_MAP[slot];
|
|
||||||
return validTimes ? validTimes.includes(idea.timeSlot) : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
|
|
||||||
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>();
|
|
||||||
|
|
||||||
function pickRandom(pool: TaggedIdea[]): TaggedIdea | null {
|
|
||||||
if (pool.length === 0) return null;
|
|
||||||
return pool[Math.floor(Math.random() * pool.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const slot of slots) {
|
|
||||||
const remaining = ideas.filter((i) => !usedIds.has(i.id));
|
|
||||||
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
|
|
||||||
|
|
||||||
const p1 = remaining.filter(
|
|
||||||
(i) => matchesSlot(i, slot) && preferredCategories.includes(i.category),
|
|
||||||
);
|
|
||||||
let picked = pickRandom(p1);
|
|
||||||
|
|
||||||
if (!picked) {
|
|
||||||
for (const cat of preferredCategories) {
|
|
||||||
const pool = remaining.filter((i) => i.category === cat);
|
|
||||||
picked = pickRandom(pool);
|
|
||||||
if (picked) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!picked) {
|
|
||||||
const p3 = remaining.filter((i) => matchesSlot(i, slot));
|
|
||||||
picked = pickRandom(p3);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!picked) {
|
|
||||||
picked = pickRandom(remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (picked) {
|
|
||||||
selected.push(picked);
|
|
||||||
usedIds.add(picked.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Progress messages (kept for legacy path)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const PLAN_PROGRESS_MESSAGES = {
|
|
||||||
analyzing: "正在分析你们的想法...",
|
|
||||||
searching: "正在搜索地点...",
|
|
||||||
planning: "正在规划路线...",
|
|
||||||
planningDay: (day: string) => `正在规划${day}...`,
|
|
||||||
almostDone: "快好了...",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Result type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface PlanGenResult {
|
|
||||||
id: string;
|
|
||||||
days: { date: string; items: unknown[]; summary: string }[];
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Agent path: tool-calling agent
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下工具可以使用:
|
|
||||||
- list_ideas: 查看想法池中的所有活动
|
|
||||||
- search_poi: 在地图上搜索地点(支持品牌名、地名、品类搜索)
|
|
||||||
- get_travel_time: 查询两点间公共交通(地铁/公交)时间和距离
|
|
||||||
- finalize_plan: 提交最终行程方案
|
|
||||||
|
|
||||||
规划流程:
|
|
||||||
1. 先用 list_ideas 了解有哪些活动可选
|
|
||||||
2. 根据时间、多样性、强度平衡选出合适的活动组合
|
|
||||||
3. 为每个活动 search_poi 找到具体地点
|
|
||||||
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
|
|
||||||
5. 用 get_travel_time 检查关键路段,如果某段超过 45 分钟考虑换更近的地点
|
|
||||||
6. 确认地点合理后 finalize_plan 提交
|
|
||||||
|
|
||||||
规划原则:
|
|
||||||
- 地理位置相近,最小化移动距离
|
|
||||||
- 严格遵守用餐时间窗口:午餐安排在 11:30-13:00,晚餐安排在 17:30-19:30,不得超出此范围
|
|
||||||
- 尊重时间偏好(morning 的活动放 9:00-12:00,afternoon 的活动放 13:00-17:00,evening 的活动放 19:00 以后)
|
|
||||||
- 活动间留 15-30 分钟交通时间
|
|
||||||
- 高低强度交替,避免连续高体力活动
|
|
||||||
- 费用均衡,不连续安排 premium 活动
|
|
||||||
- needsBooking 的活动在 reason 中提醒预约
|
|
||||||
|
|
||||||
每次调用工具前,用一句简短的话说明你的想法(用户能看到)。`;
|
|
||||||
|
|
||||||
interface FinalizePlanDay {
|
|
||||||
date: string;
|
|
||||||
items: {
|
|
||||||
time: string;
|
|
||||||
activity: string;
|
|
||||||
poi: string;
|
|
||||||
address: string;
|
|
||||||
lat: number;
|
|
||||||
lng: number;
|
|
||||||
duration: number;
|
|
||||||
reason: string;
|
|
||||||
}[];
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAgentTools(
|
|
||||||
taggedIdeas: TaggedIdea[],
|
|
||||||
lat: number,
|
|
||||||
lng: number,
|
|
||||||
city: string,
|
|
||||||
): AgentTool[] {
|
|
||||||
const listIdeasTool: AgentTool = {
|
|
||||||
name: "list_ideas",
|
|
||||||
description: "获取想法池中所有已标记的活动想法,包含品类、时间偏好、费用、强度等信息",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {},
|
|
||||||
required: [],
|
|
||||||
},
|
|
||||||
execute: async () => {
|
|
||||||
const ideas = taggedIdeas.map((i) => ({
|
|
||||||
id: i.id,
|
|
||||||
content: i.content,
|
|
||||||
category: i.category,
|
|
||||||
timeSlot: i.timeSlot,
|
|
||||||
estimatedMinutes: i.estimatedMinutes,
|
|
||||||
costLevel: i.costLevel,
|
|
||||||
intensity: i.intensity,
|
|
||||||
needsBooking: i.needsBooking,
|
|
||||||
}));
|
|
||||||
return JSON.stringify(ideas);
|
|
||||||
},
|
|
||||||
progressBefore: () => "正在查看想法池...",
|
|
||||||
progressAfter: (_args, result) => {
|
|
||||||
const ideas = JSON.parse(result);
|
|
||||||
return `找到 ${ideas.length} 个想法`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchPoiTool: AgentTool = {
|
|
||||||
name: "search_poi",
|
|
||||||
description: "在地图上搜索地点。search_type: brand=连锁品牌, place=唯一地点, category=模糊品类搜附近",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
query: { type: "string", description: "搜索关键词(品牌名、地名或品类名)" },
|
|
||||||
search_type: {
|
|
||||||
type: "string",
|
|
||||||
enum: ["brand", "place", "category"],
|
|
||||||
description: "搜索策略",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["query", "search_type"],
|
|
||||||
},
|
|
||||||
execute: async (args) => {
|
|
||||||
const query = String(args.query ?? "");
|
|
||||||
const searchType = String(args.search_type ?? "category");
|
|
||||||
try {
|
|
||||||
const pois = await searchPois(query, searchType, lat, lng);
|
|
||||||
return JSON.stringify(pois);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("searchPoiTool failed:", e);
|
|
||||||
return JSON.stringify([]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
progressBefore: (args) => `正在搜索「${args.query}」...`,
|
|
||||||
progressAfter: (args, result) => {
|
|
||||||
const pois = JSON.parse(result);
|
|
||||||
return pois.length > 0
|
|
||||||
? `找到 ${pois.length} 个「${args.query}」相关地点`
|
|
||||||
: `未找到「${args.query}」的结果`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTravelTimeTool: AgentTool = {
|
|
||||||
name: "get_travel_time",
|
|
||||||
description: "查询两点之间的公共交通(地铁/公交)预估时间和距离。用于验证活动之间的交通是否合理",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
origin_lat: { type: "number", description: "起点纬度" },
|
|
||||||
origin_lng: { type: "number", description: "起点经度" },
|
|
||||||
dest_lat: { type: "number", description: "终点纬度" },
|
|
||||||
dest_lng: { type: "number", description: "终点经度" },
|
|
||||||
},
|
|
||||||
required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"],
|
|
||||||
},
|
|
||||||
execute: async (args) => {
|
|
||||||
try {
|
|
||||||
const result = await getTransitDirection({
|
|
||||||
originLat: Number(args.origin_lat),
|
|
||||||
originLng: Number(args.origin_lng),
|
|
||||||
destLat: Number(args.dest_lat),
|
|
||||||
destLng: Number(args.dest_lng),
|
|
||||||
city,
|
|
||||||
});
|
|
||||||
if (!result) return JSON.stringify({ error: "未找到公交路线" });
|
|
||||||
return JSON.stringify(result);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("getTravelTimeTool failed:", e);
|
|
||||||
return JSON.stringify({ error: "路线查询失败" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
progressBefore: () => "正在查询公共交通时间...",
|
|
||||||
progressAfter: (_args, result) => {
|
|
||||||
const r = JSON.parse(result);
|
|
||||||
if (r.error) return `路线查询失败`;
|
|
||||||
return `${r.description} 约 ${r.durationMin} 分钟(${r.distanceKm}km)`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const finalizePlanTool: AgentTool = {
|
|
||||||
name: "finalize_plan",
|
|
||||||
description: "提交最终行程方案。days 数组中每个元素包含 date、items(活动列表)和 summary",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
days: {
|
|
||||||
type: "array",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
date: { type: "string" },
|
|
||||||
items: {
|
|
||||||
type: "array",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
time: { type: "string", description: "开始时间,如 10:00" },
|
|
||||||
activity: { type: "string", description: "活动描述" },
|
|
||||||
poi: { type: "string", description: "具体地点名称" },
|
|
||||||
address: { type: "string", description: "详细地址" },
|
|
||||||
lat: { type: "number" },
|
|
||||||
lng: { type: "number" },
|
|
||||||
duration: { type: "number", description: "时长(分钟)" },
|
|
||||||
reason: { type: "string", description: "选择理由" },
|
|
||||||
},
|
|
||||||
required: ["time", "activity", "poi", "address", "lat", "lng", "duration", "reason"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
summary: { type: "string", description: "当天行程亮点一句话总结" },
|
|
||||||
},
|
|
||||||
required: ["date", "items", "summary"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["days"],
|
|
||||||
},
|
|
||||||
execute: async () => {
|
|
||||||
return JSON.stringify({ success: true });
|
|
||||||
},
|
|
||||||
progressBefore: () => "正在整理最终行程...",
|
|
||||||
progressAfter: () => "行程规划完成!",
|
|
||||||
};
|
|
||||||
|
|
||||||
return [listIdeasTool, searchPoiTool, getTravelTimeTool, finalizePlanTool];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAgentPlanGeneration(
|
|
||||||
room: { lat: number; lng: number; city: string },
|
|
||||||
taggedIdeas: TaggedIdea[],
|
|
||||||
availableTime: PlanGenAvailableTime,
|
|
||||||
onProgress?: (message: string) => void,
|
|
||||||
): Promise<{ days: FinalizePlanDay[] }> {
|
|
||||||
const at = availableTime;
|
|
||||||
const tools = buildAgentTools(taggedIdeas, room.lat, room.lng, room.city ?? "");
|
|
||||||
|
|
||||||
const userPrompt = `帮我规划行程。
|
|
||||||
可用时间:${at.date},${at.startHour}:00 - ${at.endHour}:00
|
|
||||||
出发地(家):纬度 ${room.lat},经度 ${room.lng}
|
|
||||||
注意:第一个活动需要从出发地出发,最后一个活动结束后需要返回出发地,请用 get_travel_time 评估出发/回程时间并计入全天时间预算(确保最后一个活动结束时间 + 回程时间 ≤ ${at.endHour}:00)。
|
|
||||||
请开始规划。`;
|
|
||||||
|
|
||||||
const result = await runAgentLoop({
|
|
||||||
systemPrompt: AGENT_SYSTEM_PROMPT,
|
|
||||||
userPrompt,
|
|
||||||
tools,
|
|
||||||
onProgress,
|
|
||||||
maxTurns: 15,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new Error("Agent 未能在限定轮次内完成规划");
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = result.finalArgs.days as FinalizePlanDay[] | undefined;
|
|
||||||
if (!Array.isArray(days) || days.length === 0) {
|
|
||||||
throw new Error("Agent 返回的行程数据无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize the items
|
|
||||||
const normalizedDays = days.map((day) => ({
|
|
||||||
date: String(day.date ?? ""),
|
|
||||||
items: Array.isArray(day.items)
|
|
||||||
? day.items.map((item) => ({
|
|
||||||
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(day.summary ?? ""),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const validDays = normalizedDays.filter((d) => d.items.length > 0);
|
|
||||||
if (validDays.length === 0) {
|
|
||||||
throw new Error("Agent 返回的行程中没有有效活动");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { days: validDays };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Legacy path: deterministic pipeline
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function runLegacyPlanGeneration(
|
|
||||||
room: { lat: number; lng: number },
|
|
||||||
taggedIdeas: TaggedIdea[],
|
|
||||||
availableTime: PlanGenAvailableTime,
|
|
||||||
onProgress?: (message: string) => void,
|
|
||||||
): Promise<{ days: { date: string; items: unknown[]; summary: string }[] }> {
|
|
||||||
const at = availableTime;
|
|
||||||
|
|
||||||
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 (e) {
|
|
||||||
console.error(`searchPois failed for "${idea.searchQuery}":`, e);
|
|
||||||
return { query: idea.searchQuery, pois: [] };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) =>
|
|
||||||
pois.map((p) => ({ ...p, rating: p.rating ?? undefined }));
|
|
||||||
|
|
||||||
const candidates: ScheduleContext["candidates"] = {};
|
|
||||||
for (const result of searchResults) {
|
|
||||||
candidates[result.query] = toCandidates(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 (e) {
|
|
||||||
console.error(`searchPois (category) failed for "${idea.searchQuery}":`, e);
|
|
||||||
return { query: idea.searchQuery, pois: [] };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
for (const result of catResults) {
|
|
||||||
candidates[result.query] = toCandidates(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,
|
|
||||||
costLevel: i.costLevel ?? undefined,
|
|
||||||
intensity: i.intensity ?? undefined,
|
|
||||||
needsBooking: i.needsBooking ?? undefined,
|
|
||||||
})),
|
|
||||||
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 is NonNullable<typeof d> => d !== null);
|
|
||||||
|
|
||||||
if (days.length === 0) {
|
|
||||||
throw new ApiError("AI 规划失败,请稍后重试", 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { days };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public entry point (signature unchanged)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Post-processing: compute real transit info from Amap and store in plan
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function queryTransit(
|
|
||||||
oLng: number,
|
|
||||||
oLat: number,
|
|
||||||
dLng: number,
|
|
||||||
dLat: number,
|
|
||||||
city: string,
|
|
||||||
): Promise<{ durationMin: number; description: string } | null> {
|
|
||||||
try {
|
|
||||||
const result = await getTransitDirection({
|
|
||||||
originLat: oLat,
|
|
||||||
originLng: oLng,
|
|
||||||
destLat: dLat,
|
|
||||||
destLng: dLng,
|
|
||||||
city,
|
|
||||||
});
|
|
||||||
if (!result) return null;
|
|
||||||
return { durationMin: result.durationMin, description: result.description };
|
|
||||||
} catch (e) {
|
|
||||||
console.error("queryTransit failed:", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnrichDay = {
|
|
||||||
date: string;
|
|
||||||
items: Record<string, unknown>[];
|
|
||||||
summary: string;
|
|
||||||
transitFromStart?: number;
|
|
||||||
transitFromStartDescription?: string;
|
|
||||||
transitToEnd?: number;
|
|
||||||
transitToEndDescription?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function enrichTransitInfo(
|
|
||||||
days: EnrichDay[],
|
|
||||||
city: string,
|
|
||||||
homeLat: number,
|
|
||||||
homeLng: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const cityParam = city || "上海";
|
|
||||||
|
|
||||||
for (const day of days) {
|
|
||||||
const items = day.items;
|
|
||||||
|
|
||||||
// From home to first activity
|
|
||||||
if (items.length > 0 && homeLat && homeLng) {
|
|
||||||
const dLat = Number(items[0].lat);
|
|
||||||
const dLng = Number(items[0].lng);
|
|
||||||
if (dLat && dLng) {
|
|
||||||
const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam);
|
|
||||||
if (result) {
|
|
||||||
day.transitFromStart = result.durationMin;
|
|
||||||
day.transitFromStartDescription = result.description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Between consecutive activities
|
|
||||||
for (let i = 0; i < items.length - 1; i++) {
|
|
||||||
const oLat = Number(items[i].lat);
|
|
||||||
const oLng = Number(items[i].lng);
|
|
||||||
const dLat = Number(items[i + 1].lat);
|
|
||||||
const dLng = Number(items[i + 1].lng);
|
|
||||||
if (!oLat || !oLng || !dLat || !dLng) continue;
|
|
||||||
const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam);
|
|
||||||
if (result) {
|
|
||||||
items[i].transitToNext = result.durationMin;
|
|
||||||
items[i].transitDescription = result.description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// From last activity back home
|
|
||||||
if (items.length > 0 && homeLat && homeLng) {
|
|
||||||
const last = items[items.length - 1];
|
|
||||||
const oLat = Number(last.lat);
|
|
||||||
const oLng = Number(last.lng);
|
|
||||||
if (oLat && oLng) {
|
|
||||||
const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam);
|
|
||||||
if (result) {
|
|
||||||
day.transitToEnd = result.durationMin;
|
|
||||||
day.transitToEndDescription = result.description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function runPlanGeneration(
|
|
||||||
roomId: string,
|
|
||||||
userId: string,
|
|
||||||
availableTime: PlanGenAvailableTime,
|
|
||||||
onProgress?: (message: string) => void,
|
|
||||||
): Promise<PlanGenResult> {
|
|
||||||
// 1. Fetch room & ideas (shared by both paths)
|
|
||||||
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,
|
|
||||||
costLevel: true,
|
|
||||||
intensity: true,
|
|
||||||
needsBooking: 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try agent path, fallback to legacy
|
|
||||||
let days: { date: string; items: unknown[]; summary: string }[];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agentResult = await runAgentPlanGeneration(
|
|
||||||
{ lat: room.lat, lng: room.lng, city: room.city ?? "" },
|
|
||||||
taggedIdeas,
|
|
||||||
availableTime,
|
|
||||||
onProgress,
|
|
||||||
);
|
|
||||||
days = agentResult.days;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("runAgentPlanGeneration failed, falling back to legacy:", e);
|
|
||||||
onProgress?.("使用备用方案规划...");
|
|
||||||
const legacyResult = await runLegacyPlanGeneration(
|
|
||||||
{ lat: room.lat, lng: room.lng },
|
|
||||||
taggedIdeas,
|
|
||||||
availableTime,
|
|
||||||
onProgress,
|
|
||||||
);
|
|
||||||
days = legacyResult.days;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Compute real transit info server-side (best-effort, errors are swallowed internally)
|
|
||||||
onProgress?.("正在查询交通信息...");
|
|
||||||
await enrichTransitInfo(
|
|
||||||
days as EnrichDay[],
|
|
||||||
room.city ?? "上海",
|
|
||||||
room.lat,
|
|
||||||
room.lng,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Save to DB (shared)
|
|
||||||
const plan = await prisma.weekendPlan.create({
|
|
||||||
data: {
|
|
||||||
roomId,
|
|
||||||
userId,
|
|
||||||
planData: JSON.stringify({ days }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: plan.id,
|
|
||||||
days,
|
|
||||||
createdAt: plan.createdAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
TEST_ROOM_DATA,
|
TEST_ROOM_DATA,
|
||||||
} from "@/__tests__/helpers/fixtures";
|
} from "@/__tests__/helpers/fixtures";
|
||||||
|
|
||||||
vi.mock("@/lib/store", () => ({
|
vi.mock("@/lib/roomRepository", () => ({
|
||||||
getRoomData: vi.fn(),
|
getRoomData: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ vi.mock("@/lib/prisma", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||||
import { getRoomData } from "@/lib/store";
|
import { getRoomData } from "@/lib/roomRepository";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
const mockGetRoomData = vi.mocked(getRoomData);
|
const mockGetRoomData = vi.mocked(getRoomData);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getRoomData } from "./store";
|
import { getRoomData } from "./roomRepository";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import type { RoomStatus, MatchType, UserProfile } from "@/types";
|
import type { RoomStatus, MatchType, UserProfile } from "@/types";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// Agent-based plan generation (tool-calling loop)
|
||||||
|
|
||||||
|
import { searchPois, getTransitDirection } from "@/lib/amap";
|
||||||
|
import { runAgentLoop, type AgentTool } from "@/lib/ai";
|
||||||
|
import type { TaggedIdea } from "./ideaSelection";
|
||||||
|
import type { PlanGenAvailableTime } from "./index";
|
||||||
|
|
||||||
|
export const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下工具可以使用:
|
||||||
|
- list_ideas: 查看想法池中的所有活动
|
||||||
|
- search_poi: 在地图上搜索地点(支持品牌名、地名、品类搜索)
|
||||||
|
- get_travel_time: 查询两点间公共交通(地铁/公交)时间和距离
|
||||||
|
- finalize_plan: 提交最终行程方案
|
||||||
|
|
||||||
|
规划流程:
|
||||||
|
1. 先用 list_ideas 了解有哪些活动可选
|
||||||
|
2. 根据时间、多样性、强度平衡选出合适的活动组合
|
||||||
|
3. 为每个活动 search_poi 找到具体地点
|
||||||
|
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
|
||||||
|
5. 用 get_travel_time 检查关键路段,如果某段超过 45 分钟考虑换更近的地点
|
||||||
|
6. 确认地点合理后 finalize_plan 提交
|
||||||
|
|
||||||
|
规划原则:
|
||||||
|
- 地理位置相近,最小化移动距离
|
||||||
|
- 严格遵守用餐时间窗口:午餐安排在 11:30-13:00,晚餐安排在 17:30-19:30,不得超出此范围
|
||||||
|
- 尊重时间偏好(morning 的活动放 9:00-12:00,afternoon 的活动放 13:00-17:00,evening 的活动放 19:00 以后)
|
||||||
|
- 活动间留 15-30 分钟交通时间
|
||||||
|
- 高低强度交替,避免连续高体力活动
|
||||||
|
- 费用均衡,不连续安排 premium 活动
|
||||||
|
- needsBooking 的活动在 reason 中提醒预约
|
||||||
|
|
||||||
|
每次调用工具前,用一句简短的话说明你的想法(用户能看到)。`;
|
||||||
|
|
||||||
|
export interface FinalizePlanDay {
|
||||||
|
date: string;
|
||||||
|
items: {
|
||||||
|
time: string;
|
||||||
|
activity: string;
|
||||||
|
poi: string;
|
||||||
|
address: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
duration: number;
|
||||||
|
reason: string;
|
||||||
|
}[];
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgentTools(
|
||||||
|
taggedIdeas: TaggedIdea[],
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
city: string,
|
||||||
|
): AgentTool[] {
|
||||||
|
const listIdeasTool: AgentTool = {
|
||||||
|
name: "list_ideas",
|
||||||
|
description: "获取想法池中所有已标记的活动想法,包含品类、时间偏好、费用、强度等信息",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
execute: async () => {
|
||||||
|
const ideas = taggedIdeas.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
content: i.content,
|
||||||
|
category: i.category,
|
||||||
|
timeSlot: i.timeSlot,
|
||||||
|
estimatedMinutes: i.estimatedMinutes,
|
||||||
|
costLevel: i.costLevel,
|
||||||
|
intensity: i.intensity,
|
||||||
|
needsBooking: i.needsBooking,
|
||||||
|
}));
|
||||||
|
return JSON.stringify(ideas);
|
||||||
|
},
|
||||||
|
progressBefore: () => "正在查看想法池...",
|
||||||
|
progressAfter: (_args, result) => {
|
||||||
|
const ideas = JSON.parse(result);
|
||||||
|
return `找到 ${ideas.length} 个想法`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchPoiTool: AgentTool = {
|
||||||
|
name: "search_poi",
|
||||||
|
description: "在地图上搜索地点。search_type: brand=连锁品牌, place=唯一地点, category=模糊品类搜附近",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "搜索关键词(品牌名、地名或品类名)" },
|
||||||
|
search_type: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["brand", "place", "category"],
|
||||||
|
description: "搜索策略",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query", "search_type"],
|
||||||
|
},
|
||||||
|
execute: async (args) => {
|
||||||
|
const query = String(args.query ?? "");
|
||||||
|
const searchType = String(args.search_type ?? "category");
|
||||||
|
try {
|
||||||
|
const pois = await searchPois(query, searchType, lat, lng);
|
||||||
|
return JSON.stringify(pois);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("searchPoiTool failed:", e);
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progressBefore: (args) => `正在搜索「${args.query}」...`,
|
||||||
|
progressAfter: (args, result) => {
|
||||||
|
const pois = JSON.parse(result);
|
||||||
|
return pois.length > 0
|
||||||
|
? `找到 ${pois.length} 个「${args.query}」相关地点`
|
||||||
|
: `未找到「${args.query}」的结果`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTravelTimeTool: AgentTool = {
|
||||||
|
name: "get_travel_time",
|
||||||
|
description: "查询两点之间的公共交通(地铁/公交)预估时间和距离。用于验证活动之间的交通是否合理",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
origin_lat: { type: "number", description: "起点纬度" },
|
||||||
|
origin_lng: { type: "number", description: "起点经度" },
|
||||||
|
dest_lat: { type: "number", description: "终点纬度" },
|
||||||
|
dest_lng: { type: "number", description: "终点经度" },
|
||||||
|
},
|
||||||
|
required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"],
|
||||||
|
},
|
||||||
|
execute: async (args) => {
|
||||||
|
try {
|
||||||
|
const result = await getTransitDirection({
|
||||||
|
originLat: Number(args.origin_lat),
|
||||||
|
originLng: Number(args.origin_lng),
|
||||||
|
destLat: Number(args.dest_lat),
|
||||||
|
destLng: Number(args.dest_lng),
|
||||||
|
city,
|
||||||
|
});
|
||||||
|
if (!result) return JSON.stringify({ error: "未找到公交路线" });
|
||||||
|
return JSON.stringify(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("getTravelTimeTool failed:", e);
|
||||||
|
return JSON.stringify({ error: "路线查询失败" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progressBefore: () => "正在查询公共交通时间...",
|
||||||
|
progressAfter: (_args, result) => {
|
||||||
|
const r = JSON.parse(result);
|
||||||
|
if (r.error) return `路线查询失败`;
|
||||||
|
return `${r.description} 约 ${r.durationMin} 分钟(${r.distanceKm}km)`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizePlanTool: AgentTool = {
|
||||||
|
name: "finalize_plan",
|
||||||
|
description: "提交最终行程方案。days 数组中每个元素包含 date、items(活动列表)和 summary",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
days: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
date: { type: "string" },
|
||||||
|
items: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
time: { type: "string", description: "开始时间,如 10:00" },
|
||||||
|
activity: { type: "string", description: "活动描述" },
|
||||||
|
poi: { type: "string", description: "具体地点名称" },
|
||||||
|
address: { type: "string", description: "详细地址" },
|
||||||
|
lat: { type: "number" },
|
||||||
|
lng: { type: "number" },
|
||||||
|
duration: { type: "number", description: "时长(分钟)" },
|
||||||
|
reason: { type: "string", description: "选择理由" },
|
||||||
|
},
|
||||||
|
required: ["time", "activity", "poi", "address", "lat", "lng", "duration", "reason"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: { type: "string", description: "当天行程亮点一句话总结" },
|
||||||
|
},
|
||||||
|
required: ["date", "items", "summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["days"],
|
||||||
|
},
|
||||||
|
execute: async () => {
|
||||||
|
return JSON.stringify({ success: true });
|
||||||
|
},
|
||||||
|
progressBefore: () => "正在整理最终行程...",
|
||||||
|
progressAfter: () => "行程规划完成!",
|
||||||
|
};
|
||||||
|
|
||||||
|
return [listIdeasTool, searchPoiTool, getTravelTimeTool, finalizePlanTool];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAgentPlanGeneration(
|
||||||
|
room: { lat: number; lng: number; city: string },
|
||||||
|
taggedIdeas: TaggedIdea[],
|
||||||
|
availableTime: PlanGenAvailableTime,
|
||||||
|
onProgress?: (message: string) => void,
|
||||||
|
): Promise<{ days: FinalizePlanDay[] }> {
|
||||||
|
const at = availableTime;
|
||||||
|
const tools = buildAgentTools(taggedIdeas, room.lat, room.lng, room.city ?? "");
|
||||||
|
|
||||||
|
const userPrompt = `帮我规划行程。
|
||||||
|
可用时间:${at.date},${at.startHour}:00 - ${at.endHour}:00
|
||||||
|
出发地(家):纬度 ${room.lat},经度 ${room.lng}
|
||||||
|
注意:第一个活动需要从出发地出发,最后一个活动结束后需要返回出发地,请用 get_travel_time 评估出发/回程时间并计入全天时间预算(确保最后一个活动结束时间 + 回程时间 ≤ ${at.endHour}:00)。
|
||||||
|
请开始规划。`;
|
||||||
|
|
||||||
|
const result = await runAgentLoop({
|
||||||
|
systemPrompt: AGENT_SYSTEM_PROMPT,
|
||||||
|
userPrompt,
|
||||||
|
tools,
|
||||||
|
onProgress,
|
||||||
|
maxTurns: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("Agent 未能在限定轮次内完成规划");
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = result.finalArgs.days as FinalizePlanDay[] | undefined;
|
||||||
|
if (!Array.isArray(days) || days.length === 0) {
|
||||||
|
throw new Error("Agent 返回的行程数据无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDays = days.map((day) => ({
|
||||||
|
date: String(day.date ?? ""),
|
||||||
|
items: Array.isArray(day.items)
|
||||||
|
? day.items.map((item) => ({
|
||||||
|
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(day.summary ?? ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const validDays = normalizedDays.filter((d) => d.items.length > 0);
|
||||||
|
if (validDays.length === 0) {
|
||||||
|
throw new Error("Agent 返回的行程中没有有效活动");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { days: validDays };
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Slot-based idea selection used by the legacy plan path
|
||||||
|
|
||||||
|
export interface TaggedIdea {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
timeSlot: string;
|
||||||
|
estimatedMinutes: number;
|
||||||
|
searchQuery: string;
|
||||||
|
searchType: string;
|
||||||
|
costLevel: string | null;
|
||||||
|
intensity: string | null;
|
||||||
|
needsBooking: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SLOT_CATEGORY_MAP: Record<string, string[]> = {
|
||||||
|
morning: ["outdoor", "nature", "sports", "culture"],
|
||||||
|
lunch: ["dining"],
|
||||||
|
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture", "experience"],
|
||||||
|
dinner: ["dining"],
|
||||||
|
evening: ["entertainment", "relaxation", "experience"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SLOT_TIME_MAP: Record<string, string[]> = {
|
||||||
|
morning: ["morning", "flexible"],
|
||||||
|
lunch: ["flexible"],
|
||||||
|
afternoon: ["afternoon", "flexible"],
|
||||||
|
dinner: ["evening", "flexible"],
|
||||||
|
evening: ["evening", "flexible"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function matchesSlot(idea: TaggedIdea, slot: string): boolean {
|
||||||
|
if (idea.timeSlot === "all_day") return true;
|
||||||
|
const validTimes = SLOT_TIME_MAP[slot];
|
||||||
|
return validTimes ? validTimes.includes(idea.timeSlot) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
|
||||||
|
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>();
|
||||||
|
|
||||||
|
function pickRandom(pool: TaggedIdea[]): TaggedIdea | null {
|
||||||
|
if (pool.length === 0) return null;
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slot of slots) {
|
||||||
|
const remaining = ideas.filter((i) => !usedIds.has(i.id));
|
||||||
|
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
|
||||||
|
|
||||||
|
const p1 = remaining.filter(
|
||||||
|
(i) => matchesSlot(i, slot) && preferredCategories.includes(i.category),
|
||||||
|
);
|
||||||
|
let picked = pickRandom(p1);
|
||||||
|
|
||||||
|
if (!picked) {
|
||||||
|
for (const cat of preferredCategories) {
|
||||||
|
const pool = remaining.filter((i) => i.category === cat);
|
||||||
|
picked = pickRandom(pool);
|
||||||
|
if (picked) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!picked) {
|
||||||
|
const p3 = remaining.filter((i) => matchesSlot(i, slot));
|
||||||
|
picked = pickRandom(p3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!picked) {
|
||||||
|
picked = pickRandom(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (picked) {
|
||||||
|
selected.push(picked);
|
||||||
|
usedIds.add(picked.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Plan generation entry point.
|
||||||
|
* Tries the agent path first, falls back to legacy pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { runAgentPlanGeneration } from "./agentPlan";
|
||||||
|
import { runLegacyPlanGeneration, PLAN_PROGRESS_MESSAGES } from "./legacyPlan";
|
||||||
|
import { enrichTransitInfo, type EnrichDay } from "./transitEnrichment";
|
||||||
|
import type { TaggedIdea } from "./ideaSelection";
|
||||||
|
|
||||||
|
export interface PlanGenAvailableTime {
|
||||||
|
date: string;
|
||||||
|
startHour: number;
|
||||||
|
endHour: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
// 1. Fetch room & ideas
|
||||||
|
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,
|
||||||
|
costLevel: true,
|
||||||
|
intensity: true,
|
||||||
|
needsBooking: 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try agent path, fallback to legacy
|
||||||
|
let days: { date: string; items: unknown[]; summary: string }[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agentResult = await runAgentPlanGeneration(
|
||||||
|
{ lat: room.lat, lng: room.lng, city: room.city ?? "" },
|
||||||
|
taggedIdeas,
|
||||||
|
availableTime,
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
days = agentResult.days;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("runAgentPlanGeneration failed, falling back to legacy:", e);
|
||||||
|
onProgress?.("使用备用方案规划...");
|
||||||
|
const legacyResult = await runLegacyPlanGeneration(
|
||||||
|
{ lat: room.lat, lng: room.lng },
|
||||||
|
taggedIdeas,
|
||||||
|
availableTime,
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
days = legacyResult.days;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Enrich transit info (best-effort)
|
||||||
|
onProgress?.("正在查询交通信息...");
|
||||||
|
await enrichTransitInfo(
|
||||||
|
days as EnrichDay[],
|
||||||
|
room.city ?? "上海",
|
||||||
|
room.lat,
|
||||||
|
room.lng,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Save to DB
|
||||||
|
const plan = await prisma.weekendPlan.create({
|
||||||
|
data: {
|
||||||
|
roomId,
|
||||||
|
userId,
|
||||||
|
planData: JSON.stringify({ days }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: plan.id,
|
||||||
|
days,
|
||||||
|
createdAt: plan.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Legacy deterministic pipeline for plan generation
|
||||||
|
|
||||||
|
import { searchPois } from "@/lib/amap";
|
||||||
|
import { generateSchedule, type ScheduleContext } from "@/lib/ai";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { selectIdeasForSlots, type TaggedIdea } from "./ideaSelection";
|
||||||
|
import type { PlanGenAvailableTime } from "./index";
|
||||||
|
|
||||||
|
export const PLAN_PROGRESS_MESSAGES = {
|
||||||
|
analyzing: "正在分析你们的想法...",
|
||||||
|
searching: "正在搜索地点...",
|
||||||
|
planning: "正在规划路线...",
|
||||||
|
planningDay: (day: string) => `正在规划${day}...`,
|
||||||
|
almostDone: "快好了...",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function runLegacyPlanGeneration(
|
||||||
|
room: { lat: number; lng: number },
|
||||||
|
taggedIdeas: TaggedIdea[],
|
||||||
|
availableTime: PlanGenAvailableTime,
|
||||||
|
onProgress?: (message: string) => void,
|
||||||
|
): Promise<{ days: { date: string; items: unknown[]; summary: string }[] }> {
|
||||||
|
const at = availableTime;
|
||||||
|
|
||||||
|
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 (e) {
|
||||||
|
console.error(`searchPois failed for "${idea.searchQuery}":`, e);
|
||||||
|
return { query: idea.searchQuery, pois: [] };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) =>
|
||||||
|
pois.map((p) => ({ ...p, rating: p.rating ?? undefined }));
|
||||||
|
|
||||||
|
const candidates: ScheduleContext["candidates"] = {};
|
||||||
|
for (const result of searchResults) {
|
||||||
|
candidates[result.query] = toCandidates(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 (e) {
|
||||||
|
console.error(`searchPois (category) failed for "${idea.searchQuery}":`, e);
|
||||||
|
return { query: idea.searchQuery, pois: [] };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const result of catResults) {
|
||||||
|
candidates[result.query] = toCandidates(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,
|
||||||
|
costLevel: i.costLevel ?? undefined,
|
||||||
|
intensity: i.intensity ?? undefined,
|
||||||
|
needsBooking: i.needsBooking ?? undefined,
|
||||||
|
})),
|
||||||
|
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 is NonNullable<typeof d> => d !== null);
|
||||||
|
|
||||||
|
if (days.length === 0) {
|
||||||
|
throw new ApiError("AI 规划失败,请稍后重试", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { days };
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Post-processing: compute real transit info from Amap and store in plan
|
||||||
|
|
||||||
|
import { getTransitDirection } from "@/lib/amap";
|
||||||
|
|
||||||
|
async function queryTransit(
|
||||||
|
oLng: number,
|
||||||
|
oLat: number,
|
||||||
|
dLng: number,
|
||||||
|
dLat: number,
|
||||||
|
city: string,
|
||||||
|
): Promise<{ durationMin: number; description: string } | null> {
|
||||||
|
try {
|
||||||
|
const result = await getTransitDirection({
|
||||||
|
originLat: oLat,
|
||||||
|
originLng: oLng,
|
||||||
|
destLat: dLat,
|
||||||
|
destLng: dLng,
|
||||||
|
city,
|
||||||
|
});
|
||||||
|
if (!result) return null;
|
||||||
|
return { durationMin: result.durationMin, description: result.description };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("queryTransit failed:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnrichDay = {
|
||||||
|
date: string;
|
||||||
|
items: Record<string, unknown>[];
|
||||||
|
summary: string;
|
||||||
|
transitFromStart?: number;
|
||||||
|
transitFromStartDescription?: string;
|
||||||
|
transitToEnd?: number;
|
||||||
|
transitToEndDescription?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function enrichTransitInfo(
|
||||||
|
days: EnrichDay[],
|
||||||
|
city: string,
|
||||||
|
homeLat: number,
|
||||||
|
homeLng: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const cityParam = city || "上海";
|
||||||
|
|
||||||
|
for (const day of days) {
|
||||||
|
const items = day.items;
|
||||||
|
|
||||||
|
// From home to first activity
|
||||||
|
if (items.length > 0 && homeLat && homeLng) {
|
||||||
|
const dLat = Number(items[0].lat);
|
||||||
|
const dLng = Number(items[0].lng);
|
||||||
|
if (dLat && dLng) {
|
||||||
|
const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam);
|
||||||
|
if (result) {
|
||||||
|
day.transitFromStart = result.durationMin;
|
||||||
|
day.transitFromStartDescription = result.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Between consecutive activities
|
||||||
|
for (let i = 0; i < items.length - 1; i++) {
|
||||||
|
const oLat = Number(items[i].lat);
|
||||||
|
const oLng = Number(items[i].lng);
|
||||||
|
const dLat = Number(items[i + 1].lat);
|
||||||
|
const dLng = Number(items[i + 1].lng);
|
||||||
|
if (!oLat || !oLng || !dLat || !dLng) continue;
|
||||||
|
const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam);
|
||||||
|
if (result) {
|
||||||
|
items[i].transitToNext = result.durationMin;
|
||||||
|
items[i].transitDescription = result.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From last activity back home
|
||||||
|
if (items.length > 0 && homeLat && homeLng) {
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const oLat = Number(last.lat);
|
||||||
|
const oLng = Number(last.lng);
|
||||||
|
if (oLat && oLng) {
|
||||||
|
const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam);
|
||||||
|
if (result) {
|
||||||
|
day.transitToEnd = result.durationMin;
|
||||||
|
day.transitToEndDescription = result.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map "周六"/"周日" to the next occurrence of that weekday from a reference date.
|
||||||
|
* Returns a Date at 00:00 of that day.
|
||||||
|
*/
|
||||||
|
export function nextWeekday(dayLabel: string, from: Date): Date {
|
||||||
|
const targetDow = dayLabel === "周日" ? 0 : 6; // Sunday=0, Saturday=6
|
||||||
|
const d = new Date(from);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
const diff = (targetDow - d.getDay() + 7) % 7;
|
||||||
|
d.setDate(d.getDate() + (diff === 0 ? 0 : diff));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeEndTime(planData: string, now: Date): Date | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(planData);
|
||||||
|
const days = parsed.days as { date: string; items: { time: string; duration: number }[] }[];
|
||||||
|
if (!days?.length) return null;
|
||||||
|
|
||||||
|
const lastDay = days[days.length - 1];
|
||||||
|
const lastItem = lastDay.items[lastDay.items.length - 1];
|
||||||
|
if (!lastItem) return null;
|
||||||
|
|
||||||
|
const base = nextWeekday(lastDay.date, now);
|
||||||
|
const [h, m] = lastItem.time.split(":").map(Number);
|
||||||
|
base.setHours(h, m, 0, 0);
|
||||||
|
base.setMinutes(base.getMinutes() + (lastItem.duration || 60));
|
||||||
|
|
||||||
|
if (base.getTime() < now.getTime()) {
|
||||||
|
base.setDate(base.getDate() + 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("computeEndTime failed:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handlePlanUpdate(
|
||||||
|
planId: string,
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
days: unknown[],
|
||||||
|
): Promise<{ ok: true; endTime?: Date | null }> {
|
||||||
|
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
|
||||||
|
if (!plan) throw new ApiError("计划不存在", 404);
|
||||||
|
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
|
||||||
|
|
||||||
|
const act = action || "accept";
|
||||||
|
|
||||||
|
if (act === "accept") {
|
||||||
|
if (plan.status !== "active") throw new ApiError("该计划无法接受", 400);
|
||||||
|
const endTime = computeEndTime(plan.planData, new Date());
|
||||||
|
await prisma.weekendPlan.update({
|
||||||
|
where: { id: planId },
|
||||||
|
data: { status: "accepted", endTime },
|
||||||
|
});
|
||||||
|
return { ok: true, endTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (act === "complete" || act === "expire") {
|
||||||
|
if (plan.status !== "accepted") throw new ApiError("只能更新已接受的计划", 400);
|
||||||
|
await prisma.weekendPlan.update({
|
||||||
|
where: { id: planId },
|
||||||
|
data: { status: act === "complete" ? "completed" : "expired" },
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (act === "update_plan") {
|
||||||
|
if (plan.status !== "active" && plan.status !== "accepted") {
|
||||||
|
throw new ApiError("只能编辑进行中的计划", 400);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(days) || days.length === 0) {
|
||||||
|
throw new ApiError("days 数据无效", 400);
|
||||||
|
}
|
||||||
|
const newPlanData = JSON.stringify({ days });
|
||||||
|
await prisma.weekendPlan.update({
|
||||||
|
where: { id: planId },
|
||||||
|
data: {
|
||||||
|
planData: newPlanData,
|
||||||
|
...(plan.status === "accepted"
|
||||||
|
? { endTime: computeEndTime(newPlanData, new Date()) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError("无效的操作", 400);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function getLatestPlan(roomId: string, userId: string) {
|
||||||
|
const plan = await prisma.weekendPlan.findFirst({
|
||||||
|
where: { roomId, userId, status: "accepted" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, planData: true, endTime: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plan) return { plan: null };
|
||||||
|
const parsed = JSON.parse(plan.planData);
|
||||||
|
return {
|
||||||
|
plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingPlans(userId: string) {
|
||||||
|
const plans = await prisma.weekendPlan.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: "accepted",
|
||||||
|
endTime: { not: null, lt: new Date() },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, planData: true, roomId: true, createdAt: true },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
plans.map(async (p) => {
|
||||||
|
const room = await prisma.blindBoxRoom.findUnique({
|
||||||
|
where: { id: p.roomId },
|
||||||
|
select: { name: true, code: true },
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(p.planData);
|
||||||
|
const days = parsed.days as { date: string; items: { activity: string }[] }[];
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
roomName: room?.name ?? "未知房间",
|
||||||
|
roomCode: room?.code ?? "",
|
||||||
|
date: days.map((d) => d.date).join(" + "),
|
||||||
|
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { pending: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistoryPlans(userId: string) {
|
||||||
|
const plans = await prisma.weekendPlan.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: { in: ["completed", "expired"] },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, planData: true, status: true, roomId: true, createdAt: true },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
plans.map(async (p) => {
|
||||||
|
const room = await prisma.blindBoxRoom.findUnique({
|
||||||
|
where: { id: p.roomId },
|
||||||
|
select: { name: true, code: true },
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(p.planData);
|
||||||
|
const days = parsed.days as { date: string; items: { activity: string }[] }[];
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
status: p.status,
|
||||||
|
roomName: room?.name ?? "未知房间",
|
||||||
|
roomCode: room?.code ?? "",
|
||||||
|
date: days.map((d) => d.date).join(" + "),
|
||||||
|
dayCount: days.length,
|
||||||
|
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { history: result };
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { prisma } from "./prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { Restaurant, SceneType } from "@/types";
|
||||||
|
|
||||||
|
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
export interface RoomData {
|
||||||
|
users: string[];
|
||||||
|
restaurants: Restaurant[];
|
||||||
|
likes: Record<string, string[]>;
|
||||||
|
swipeCounts: Record<string, number>;
|
||||||
|
match: string | null;
|
||||||
|
creatorId: string;
|
||||||
|
locked: boolean;
|
||||||
|
kickedUsers: string[];
|
||||||
|
scene: SceneType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
function generateRoomId(): string {
|
||||||
|
let id = "";
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
id += ROOM_ID_CHARS[Math.floor(Math.random() * ROOM_ID_CHARS.length)];
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastCleanup = 0;
|
||||||
|
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
async function cleanupExpiredRooms() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||||
|
lastCleanup = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { count } = await prisma.room.deleteMany({
|
||||||
|
where: { expiresAt: { lt: new Date() } },
|
||||||
|
});
|
||||||
|
if (count > 0) {
|
||||||
|
console.log(`Cleaned up ${count} expired room(s)`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Room cleanup failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRoom(restaurants: Restaurant[], creatorId: string, scene: SceneType = "eat"): Promise<string> {
|
||||||
|
await cleanupExpiredRooms();
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
|
||||||
|
|
||||||
|
for (let attempts = 0; attempts < 20; attempts++) {
|
||||||
|
const roomId = generateRoomId();
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.room.create({
|
||||||
|
data: { id: roomId, creatorId, scene, expiresAt },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (restaurants.length > 0) {
|
||||||
|
await tx.roomRestaurant.createMany({
|
||||||
|
data: restaurants.map((r) => ({
|
||||||
|
roomId,
|
||||||
|
restaurantData: JSON.stringify(r),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return roomId;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("无法生成唯一房间号,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRoomData(roomId: string): Promise<RoomData | null> {
|
||||||
|
const room = await prisma.room.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
include: {
|
||||||
|
members: true,
|
||||||
|
restaurants: true,
|
||||||
|
likes: true,
|
||||||
|
swipes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!room) return null;
|
||||||
|
if (room.expiresAt < new Date()) {
|
||||||
|
await prisma.room.delete({ where: { id: roomId } }).catch((e) => {
|
||||||
|
console.error(`Failed to delete expired room ${roomId}:`, e);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = room.members.filter((m) => !m.kicked).map((m) => m.userId);
|
||||||
|
const kickedUsers = room.members.filter((m) => m.kicked).map((m) => m.userId);
|
||||||
|
const restaurants: Restaurant[] = room.restaurants.map((r) => JSON.parse(r.restaurantData));
|
||||||
|
|
||||||
|
const likes: Record<string, string[]> = {};
|
||||||
|
for (const like of room.likes) {
|
||||||
|
if (!likes[like.restaurantId]) likes[like.restaurantId] = [];
|
||||||
|
likes[like.restaurantId].push(like.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const swipeCounts: Record<string, number> = {};
|
||||||
|
for (const swipe of room.swipes) {
|
||||||
|
swipeCounts[swipe.userId] = swipe.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users,
|
||||||
|
restaurants,
|
||||||
|
likes,
|
||||||
|
swipeCounts,
|
||||||
|
match: room.match,
|
||||||
|
creatorId: room.creatorId,
|
||||||
|
locked: room.locked,
|
||||||
|
kickedUsers,
|
||||||
|
scene: room.scene as SceneType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-room mutex to serialize concurrent read-modify-write operations.
|
||||||
|
*/
|
||||||
|
const roomLocks = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
function withRoomLock<T>(roomId: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = roomLocks.get(roomId) ?? Promise.resolve();
|
||||||
|
const next = prev.then(fn, fn);
|
||||||
|
roomLocks.set(roomId, next);
|
||||||
|
next.finally(() => {
|
||||||
|
if (roomLocks.get(roomId) === next) {
|
||||||
|
roomLocks.delete(roomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic read-modify-write with per-room serialization.
|
||||||
|
* Translates RoomData mutations into relational table operations.
|
||||||
|
*/
|
||||||
|
export async function atomicUpdateRoom(
|
||||||
|
roomId: string,
|
||||||
|
updater: (data: RoomData) => RoomData,
|
||||||
|
): Promise<RoomData | null> {
|
||||||
|
return withRoomLock(roomId, async () => {
|
||||||
|
const current = await getRoomData(roomId);
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
const updated = updater(current);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Update room-level fields
|
||||||
|
await tx.room.update({
|
||||||
|
where: { id: roomId },
|
||||||
|
data: {
|
||||||
|
scene: updated.scene,
|
||||||
|
locked: updated.locked,
|
||||||
|
match: updated.match,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync all members (users + kicked)
|
||||||
|
const allMemberIds = new Set([...updated.users, ...updated.kickedUsers]);
|
||||||
|
const kickedSet = new Set(updated.kickedUsers);
|
||||||
|
|
||||||
|
for (const userId of allMemberIds) {
|
||||||
|
await tx.roomMember.upsert({
|
||||||
|
where: { roomId_userId: { roomId, userId } },
|
||||||
|
update: { kicked: kickedSet.has(userId) },
|
||||||
|
create: { roomId, userId, kicked: kickedSet.has(userId) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync likes: rebuild from updated data
|
||||||
|
if (JSON.stringify(current.likes) !== JSON.stringify(updated.likes)) {
|
||||||
|
await tx.roomLike.deleteMany({ where: { roomId } });
|
||||||
|
const likeRows: { roomId: string; userId: string; restaurantId: string }[] = [];
|
||||||
|
for (const [restaurantId, userIds] of Object.entries(updated.likes)) {
|
||||||
|
for (const userId of userIds) {
|
||||||
|
likeRows.push({ roomId, userId, restaurantId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (likeRows.length > 0) {
|
||||||
|
await tx.roomLike.createMany({ data: likeRows });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync swipes
|
||||||
|
if (JSON.stringify(current.swipeCounts) !== JSON.stringify(updated.swipeCounts)) {
|
||||||
|
for (const [userId, count] of Object.entries(updated.swipeCounts)) {
|
||||||
|
await tx.roomSwipe.upsert({
|
||||||
|
where: { roomId_userId: { roomId, userId } },
|
||||||
|
update: { count },
|
||||||
|
create: { roomId, userId, count },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const IdeaTagsSchema = z.object({
|
||||||
|
category: z.enum([
|
||||||
|
"dining", "outdoor", "entertainment", "shopping", "sports",
|
||||||
|
"culture", "relaxation", "experience", "nature",
|
||||||
|
]),
|
||||||
|
timeSlot: z.enum(["morning", "afternoon", "evening", "flexible", "all_day"]),
|
||||||
|
estimatedMinutes: z.number().int().positive(),
|
||||||
|
costLevel: z.enum(["free", "budget", "moderate", "premium"]),
|
||||||
|
intensity: z.enum(["chill", "moderate", "active"]),
|
||||||
|
needsBooking: z.boolean(),
|
||||||
|
searchQuery: z.string().min(1),
|
||||||
|
searchType: z.enum(["brand", "place", "category"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IdeaTagsSchema = z.infer<typeof IdeaTagsSchema>;
|
||||||
|
|
||||||
|
export const SuggestIdeasSchema = z.object({
|
||||||
|
suggestions: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PlanItemSchema = z.object({
|
||||||
|
time: z.string(),
|
||||||
|
activity: z.string(),
|
||||||
|
poi: z.string(),
|
||||||
|
address: z.string(),
|
||||||
|
lat: z.number(),
|
||||||
|
lng: z.number(),
|
||||||
|
duration: z.number(),
|
||||||
|
reason: z.string(),
|
||||||
|
transitToNext: z.number().optional(),
|
||||||
|
transitDescription: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GenerateScheduleSchema = z.object({
|
||||||
|
items: z.array(PlanItemSchema).min(1),
|
||||||
|
summary: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RefinePlanSchema = z.object({
|
||||||
|
days: z.array(
|
||||||
|
z.object({
|
||||||
|
date: z.string(),
|
||||||
|
items: z.array(z.record(z.string(), z.unknown())),
|
||||||
|
}),
|
||||||
|
).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SuggestAlternativesSchema = z.object({
|
||||||
|
alternatives: z.array(
|
||||||
|
z.object({
|
||||||
|
activity: z.string(),
|
||||||
|
searchQuery: z.string(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const AvailableTimeSchema = z.object({
|
||||||
|
date: z.string().min(1),
|
||||||
|
startHour: z.number().int().min(0).max(23),
|
||||||
|
endHour: z.number().int().min(1).max(24),
|
||||||
|
}).refine((v) => v.endHour > v.startHour, { message: "endHour must be after startHour" });
|
||||||
|
|
||||||
|
export const CreatePlanSchema = z.object({
|
||||||
|
roomId: z.string().min(1, "roomId 不能为空"),
|
||||||
|
availableTime: AvailableTimeSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdatePlanSchema = z.object({
|
||||||
|
planId: z.string().min(1, "planId 不能为空"),
|
||||||
|
action: z.string().optional(),
|
||||||
|
days: z.array(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateRoomSchema = z.object({
|
||||||
|
restaurants: z.array(z.unknown()).min(1),
|
||||||
|
scene: z.string().optional(),
|
||||||
|
});
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { prisma } from "./prisma";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { Restaurant, SceneType } from "@/types";
|
|
||||||
|
|
||||||
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
|
|
||||||
export interface RoomData {
|
|
||||||
users: string[];
|
|
||||||
restaurants: Restaurant[];
|
|
||||||
likes: Record<string, string[]>;
|
|
||||||
swipeCounts: Record<string, number>;
|
|
||||||
match: string | null;
|
|
||||||
creatorId: string;
|
|
||||||
locked: boolean;
|
|
||||||
kickedUsers: string[];
|
|
||||||
scene: SceneType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
|
|
||||||
function generateRoomId(): string {
|
|
||||||
let id = "";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
id += ROOM_ID_CHARS[Math.floor(Math.random() * ROOM_ID_CHARS.length)];
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(raw: Partial<RoomData>): RoomData {
|
|
||||||
return {
|
|
||||||
users: raw.users ?? [],
|
|
||||||
restaurants: raw.restaurants ?? [],
|
|
||||||
likes: raw.likes ?? {},
|
|
||||||
swipeCounts: raw.swipeCounts ?? {},
|
|
||||||
match: raw.match ?? null,
|
|
||||||
creatorId: raw.creatorId ?? "",
|
|
||||||
locked: raw.locked ?? false,
|
|
||||||
kickedUsers: raw.kickedUsers ?? [],
|
|
||||||
scene: raw.scene ?? "eat",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastCleanup = 0;
|
|
||||||
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // at most once per hour
|
|
||||||
|
|
||||||
async function cleanupExpiredRooms() {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
|
||||||
lastCleanup = now;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { count } = await prisma.room.deleteMany({
|
|
||||||
where: { expiresAt: { lt: new Date() } },
|
|
||||||
});
|
|
||||||
if (count > 0) {
|
|
||||||
console.log(`Cleaned up ${count} expired room(s)`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Room cleanup failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRoom(restaurants: Restaurant[], creatorId: string, scene: SceneType = "eat"): Promise<string> {
|
|
||||||
await cleanupExpiredRooms();
|
|
||||||
|
|
||||||
const data: RoomData = {
|
|
||||||
users: [],
|
|
||||||
restaurants,
|
|
||||||
likes: {},
|
|
||||||
swipeCounts: {},
|
|
||||||
match: null,
|
|
||||||
creatorId,
|
|
||||||
locked: false,
|
|
||||||
kickedUsers: [],
|
|
||||||
scene,
|
|
||||||
};
|
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
|
|
||||||
const payload = JSON.stringify(data);
|
|
||||||
|
|
||||||
for (let attempts = 0; attempts < 20; attempts++) {
|
|
||||||
const roomId = generateRoomId();
|
|
||||||
try {
|
|
||||||
await prisma.room.create({
|
|
||||||
data: { id: roomId, data: payload, expiresAt },
|
|
||||||
});
|
|
||||||
return roomId;
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("无法生成唯一房间号,请稍后重试");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRoomData(
|
|
||||||
roomId: string,
|
|
||||||
): Promise<RoomData | null> {
|
|
||||||
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
|
||||||
if (!room) return null;
|
|
||||||
if (room.expiresAt < new Date()) {
|
|
||||||
await prisma.room.delete({ where: { id: roomId } }).catch((e) => {
|
|
||||||
console.error(`Failed to delete expired room ${roomId}:`, e);
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return normalize(JSON.parse(room.data));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-room mutex to serialize concurrent read-modify-write operations.
|
|
||||||
* SQLite doesn't support row-level locks (SELECT ... FOR UPDATE),
|
|
||||||
* so we use an application-level lock to prevent lost updates.
|
|
||||||
*/
|
|
||||||
const roomLocks = new Map<string, Promise<unknown>>();
|
|
||||||
|
|
||||||
function withRoomLock<T>(roomId: string, fn: () => Promise<T>): Promise<T> {
|
|
||||||
const prev = roomLocks.get(roomId) ?? Promise.resolve();
|
|
||||||
const next = prev.then(fn, fn);
|
|
||||||
roomLocks.set(roomId, next);
|
|
||||||
// Cleanup the lock entry when the chain settles
|
|
||||||
next.finally(() => {
|
|
||||||
if (roomLocks.get(roomId) === next) {
|
|
||||||
roomLocks.delete(roomId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomic read-modify-write with per-room serialization.
|
|
||||||
* Uses an application-level mutex to prevent concurrent lost updates,
|
|
||||||
* since SQLite lacks row-level locking.
|
|
||||||
*/
|
|
||||||
export async function atomicUpdateRoom(
|
|
||||||
roomId: string,
|
|
||||||
updater: (data: RoomData) => RoomData,
|
|
||||||
): Promise<RoomData | null> {
|
|
||||||
return withRoomLock(roomId, () =>
|
|
||||||
prisma.$transaction(async (tx) => {
|
|
||||||
const room = await tx.room.findUnique({ where: { id: roomId } });
|
|
||||||
if (!room) return null;
|
|
||||||
|
|
||||||
const data = normalize(JSON.parse(room.data));
|
|
||||||
const updated = updater(data);
|
|
||||||
|
|
||||||
await tx.room.update({
|
|
||||||
where: { id: roomId },
|
|
||||||
data: { data: JSON.stringify(updated) },
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user