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
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
interface PendingContract {
id: string;
roomName: string;
date: string;
activities: string[];
}
interface ContractCompletionModalProps {
contracts: PendingContract[];
userId: string;
onDone: () => void;
}
export default function ContractCompletionModal({
contracts,
userId,
onDone,
}: ContractCompletionModalProps) {
const [current, setCurrent] = useState(0);
const [loading, setLoading] = useState(false);
if (contracts.length === 0) return null;
const contract = contracts[current];
const handleAction = async (action: "complete" | "expire") => {
if (loading) return;
setLoading(true);
try {
await fetch("/api/blindbox/plan", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId: contract.id, userId, action }),
});
} catch { /* best-effort */ }
setLoading(false);
if (current < contracts.length - 1) {
setCurrent((c) => c + 1);
} else {
onDone();
}
};
const summary =
contract.activities.length <= 3
? contract.activities.join(" → ")
: contract.activities.slice(0, 3).join(" → ") + "…";
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-6 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="w-full max-w-sm overflow-hidden rounded-2xl bg-surface shadow-2xl ring-1 ring-border"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
>
<div className="bg-linear-to-br from-purple-600/15 to-indigo-600/15 px-5 py-4">
<p className="text-center text-xs font-bold tracking-wider text-purple-400/70">
</p>
<p className="mt-2 text-center text-base font-bold text-heading">
{contract.roomName}
</p>
<p className="mt-1 text-center text-[11px] text-muted">
{contract.date} · {summary}
</p>
</div>
<div className="px-5 py-4">
<p className="text-center text-sm text-secondary">
</p>
<div className="mt-4 flex gap-3">
<button
onClick={() => handleAction("complete")}
disabled={loading}
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-emerald-600 py-3 text-sm font-bold text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
>
{loading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<CheckCircle2 size={16} />
)}
</button>
<button
onClick={() => handleAction("expire")}
disabled={loading}
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-surface py-3 text-sm font-medium text-muted ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-50"
>
{loading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<XCircle size={16} />
)}
</button>
</div>
{contracts.length > 1 && (
<p className="mt-3 text-center text-[10px] text-dim">
{current + 1} / {contracts.length}
</p>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
export type { PendingContract };