feat: 契约生命周期 + 到期通知 + 成就墙

- 扩展 WeekendPlan schema: 新增 endTime 字段与 userId 索引
- PATCH /api/blindbox/plan 支持 accept/complete/expire 操作,
  接受时自动计算契约结束时间
- GET /api/blindbox/plan 支持 mode 参数 (latest/pending/history)
- 房间页接受契约后自动返回想法池,顶部显示"契约进行中"指示器
- 契约到期时触发浏览器通知 + 页面加载时弹出完成确认弹窗
- 新增 /achievements 成就墙页面:统计数据 + 决策记录/契约记录双标签
- 首页和个人中心新增成就墙入口
This commit is contained in:
2026-02-27 02:12:18 +08:00
parent d8e42b860f
commit c17b13b6a8
10 changed files with 889 additions and 36 deletions
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { apiHandler, requireUserId } from "@/lib/api";
export const GET = apiHandler(async (req) => {
const userId = req.nextUrl.searchParams.get("userId");
requireUserId(userId);
const [decisions, contracts] = await Promise.all([
prisma.decision.findMany({
where: { userId: userId! },
orderBy: { createdAt: "desc" },
take: 50,
}),
prisma.weekendPlan.findMany({
where: {
userId: userId!,
status: { in: ["completed", "expired"] },
},
orderBy: { createdAt: "desc" },
take: 50,
select: {
id: true,
planData: true,
status: true,
roomId: true,
createdAt: true,
},
}),
]);
const roomIds = [...new Set(contracts.map((c) => c.roomId))];
const rooms = await prisma.blindBoxRoom.findMany({
where: { id: { in: roomIds } },
select: { id: true, name: true, code: true },
});
const roomMap = new Map(rooms.map((r) => [r.id, r]));
const completedCount = contracts.filter((c) => c.status === "completed").length;
const contractRecords = contracts.map((c) => {
const parsed = JSON.parse(c.planData);
const days = parsed.days as { date: string; items: { activity: string }[] }[];
const room = roomMap.get(c.roomId);
return {
id: c.id,
status: c.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: c.createdAt.toISOString(),
};
});
const decisionRecords = decisions.map((d) => ({
id: d.id,
roomId: d.roomId,
restaurantName: d.restaurantName,
restaurantData: JSON.parse(d.restaurantData),
matchType: d.matchType,
participants: d.participants,
createdAt: d.createdAt.toISOString(),
}));
return NextResponse.json({
stats: {
totalDecisions: decisions.length,
totalContracts: contracts.length,
completedContracts: completedCount,
completionRate: contracts.length > 0 ? Math.round((completedCount / contracts.length) * 100) : 0,
},
decisions: decisionRecords,
contracts: contractRecords,
});
});