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:
@@ -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 };
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { CheckCircle2, XCircle, ChevronDown } from "lucide-react";
|
||||
import type { ContractRecord } from "@/types";
|
||||
|
||||
interface ContractHistoryItemProps {
|
||||
record: ContractRecord;
|
||||
}
|
||||
|
||||
export default function ContractHistoryItem({ record }: ContractHistoryItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isCompleted = record.status === "completed";
|
||||
|
||||
const summary =
|
||||
record.activities.length <= 3
|
||||
? record.activities.join(" → ")
|
||||
: record.activities.slice(0, 3).join(" → ") + "…";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full rounded-xl bg-elevated p-3 text-left transition-colors active:bg-subtle"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
|
||||
isCompleted
|
||||
? "bg-emerald-600/15 text-emerald-400"
|
||||
: "bg-rose-600/15 text-rose-400/70"
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <CheckCircle2 size={15} /> : <XCircle size={15} />}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-heading">
|
||||
{record.roomName}
|
||||
</p>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold ${
|
||||
isCompleted
|
||||
? "bg-emerald-600/15 text-emerald-400"
|
||||
: "bg-rose-600/15 text-rose-400/70"
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? "已完成" : "未完成"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
|
||||
<span>{record.date}</span>
|
||||
<span>·</span>
|
||||
<span>{record.activities.length} 项活动</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{new Date(record.createdAt).toLocaleDateString("zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-dim">{summary}</p>
|
||||
</div>
|
||||
|
||||
<motion.span
|
||||
animate={{ rotate: expanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mt-1 shrink-0 text-muted"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</motion.span>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<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 border-t border-border pt-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{record.activities.map((activity, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-600/15 text-[10px] font-bold text-purple-400">
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className="text-xs text-secondary">{activity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user