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
+147 -24
View File
@@ -311,46 +311,169 @@ 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 computed end time is in the past, it's for next week
if (base.getTime() < now.getTime()) {
base.setDate(base.getDate() + 7);
}
return base;
} catch {
return null;
}
}
export const PATCH = apiHandler(async (req) => {
const { planId, userId } = await req.json();
const { planId, userId, action } = await req.json();
requireUserId(userId);
if (!planId) throw new ApiError("planId 不能为空");
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
if (!plan) throw new ApiError("计划不存在", 404);
if (plan.userId !== userId) throw new ApiError("只能接受自己的计划", 403);
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
await prisma.weekendPlan.update({
where: { id: planId },
data: { status: "accepted" },
});
const act = action || "accept";
return NextResponse.json({ ok: true });
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 });
}
throw new ApiError("无效的操作", 400);
});
export const GET = apiHandler(async (req) => {
const { searchParams } = new URL(req.url);
const roomId = searchParams.get("roomId");
const mode = searchParams.get("mode") || "latest";
const userId = searchParams.get("userId");
requireUserId(userId);
if (!roomId) throw new ApiError("roomId 不能为空");
const plan = await prisma.weekendPlan.findFirst({
where: { roomId, userId: userId!, status: "accepted" },
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, createdAt: true },
});
if (mode === "latest") {
const roomId = searchParams.get("roomId");
if (!roomId) throw new ApiError("roomId 不能为空");
if (!plan) return NextResponse.json({ plan: null });
const plan = await prisma.weekendPlan.findFirst({
where: { roomId, userId: userId!, status: "accepted" },
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, endTime: true, createdAt: true },
});
const parsed = JSON.parse(plan.planData);
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 },
});
}
return NextResponse.json({
plan: {
id: plan.id,
days: parsed.days,
createdAt: plan.createdAt,
},
});
if (mode === "pending") {
const plans = await prisma.weekendPlan.findMany({
where: {
userId: 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") {
const plans = await prisma.weekendPlan.findMany({
where: {
userId: 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);
});