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
+274
View File
@@ -0,0 +1,274 @@
"use client";
import { useState, useEffect } 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 { 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 type { DecisionRecord, ContractRecord, Restaurant } from "@/types";
type Tab = "decisions" | "contracts";
interface Stats {
totalDecisions: number;
totalContracts: number;
completedContracts: number;
completionRate: number;
}
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() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("decisions");
const [stats, setStats] = useState<Stats>({
totalDecisions: 0,
totalContracts: 0,
completedContracts: 0,
completionRate: 0,
});
const [decisions, setDecisions] = useState<DecisionRecord[]>([]);
const [contracts, setContracts] = useState<ContractRecord[]>([]);
useEffect(() => {
if (!isRegistered()) {
router.replace("/");
return;
}
const p = getCachedProfile();
if (!p) return;
(async () => {
try {
const res = await fetch(`/api/user/achievements?userId=${p.id}`);
if (!res.ok) return;
const data = await res.json();
setStats(data.stats);
setDecisions(data.decisions);
setContracts(data.contracts);
} catch { /* ignore */ }
finally { setLoading(false); }
})();
}, [router]);
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}
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}
title="还没有契约记录"
subtitle="完成或过期的契约会在这里显示"
color="purple"
/>
) : (
contracts.map((c) => (
<ContractHistoryItem key={c.id} record={c} />
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
<div className="h-8 shrink-0" />
</div>
);
}