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:
@@ -114,15 +114,17 @@ model BlindBoxIdea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model WeekendPlan {
|
model WeekendPlan {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
roomId String
|
roomId String
|
||||||
userId String
|
userId String
|
||||||
planData String
|
planData String
|
||||||
status String @default("active")
|
status String @default("active")
|
||||||
createdAt DateTime @default(now())
|
endTime DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([roomId])
|
@@index([roomId])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => {
|
export const PATCH = apiHandler(async (req) => {
|
||||||
const { planId, userId } = await req.json();
|
const { planId, userId, action } = await req.json();
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
if (!planId) throw new ApiError("planId 不能为空");
|
if (!planId) throw new ApiError("planId 不能为空");
|
||||||
|
|
||||||
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
|
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
|
||||||
if (!plan) throw new ApiError("计划不存在", 404);
|
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({
|
const act = action || "accept";
|
||||||
where: { id: planId },
|
|
||||||
data: { status: "accepted" },
|
|
||||||
});
|
|
||||||
|
|
||||||
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) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const roomId = searchParams.get("roomId");
|
const mode = searchParams.get("mode") || "latest";
|
||||||
const userId = searchParams.get("userId");
|
const userId = searchParams.get("userId");
|
||||||
|
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
if (!roomId) throw new ApiError("roomId 不能为空");
|
|
||||||
|
|
||||||
const plan = await prisma.weekendPlan.findFirst({
|
if (mode === "latest") {
|
||||||
where: { roomId, userId: userId!, status: "accepted" },
|
const roomId = searchParams.get("roomId");
|
||||||
orderBy: { createdAt: "desc" },
|
if (!roomId) throw new ApiError("roomId 不能为空");
|
||||||
select: { id: true, planData: true, createdAt: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
if (mode === "pending") {
|
||||||
plan: {
|
const plans = await prisma.weekendPlan.findMany({
|
||||||
id: plan.id,
|
where: {
|
||||||
days: parsed.days,
|
userId: userId!,
|
||||||
createdAt: plan.createdAt,
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Calendar,
|
Calendar,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
ClipboardCheck,
|
||||||
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||||
@@ -27,6 +29,7 @@ import BlindboxMyIdeas, { type MyIdea } from "@/components/BlindboxMyIdeas";
|
|||||||
import BlindboxDrawnHistory, { type DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
import BlindboxDrawnHistory, { type DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
||||||
import WeekendTimeSelector from "@/components/WeekendTimeSelector";
|
import WeekendTimeSelector from "@/components/WeekendTimeSelector";
|
||||||
import BlindboxPlan from "@/components/BlindboxPlan";
|
import BlindboxPlan from "@/components/BlindboxPlan";
|
||||||
|
import ContractCompletionModal, { type PendingContract } from "@/components/ContractCompletionModal";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { useShare } from "@/hooks/useShare";
|
import { useShare } from "@/hooks/useShare";
|
||||||
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
|
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
|
||||||
@@ -76,6 +79,12 @@ export default function BlindboxRoomPage() {
|
|||||||
const [planAccepted, setPlanAccepted] = useState(false);
|
const [planAccepted, setPlanAccepted] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
||||||
|
const [activeContract, setActiveContract] = useState<{
|
||||||
|
id: string;
|
||||||
|
days: WeekendPlanData[];
|
||||||
|
endTime: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [pendingContracts, setPendingContracts] = useState<PendingContract[]>([]);
|
||||||
|
|
||||||
const boxControls = useAnimation();
|
const boxControls = useAnimation();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -141,14 +150,15 @@ export default function BlindboxRoomPage() {
|
|||||||
const p = getCachedProfile();
|
const p = getCachedProfile();
|
||||||
if (!room || !p) return;
|
if (!room || !p) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/blindbox/plan?roomId=${room.id}&userId=${p.id}`);
|
const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${p.id}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.plan) {
|
if (data.plan) {
|
||||||
setPlanId(data.plan.id);
|
setActiveContract({
|
||||||
setPlanDays(data.plan.days);
|
id: data.plan.id,
|
||||||
setPlanAccepted(true);
|
days: data.plan.days,
|
||||||
setPhase("plan_reveal");
|
endTime: data.plan.endTime ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [room]);
|
}, [room]);
|
||||||
@@ -160,6 +170,53 @@ export default function BlindboxRoomPage() {
|
|||||||
}
|
}
|
||||||
}, [isMember, room, fetchIdeas, fetchAcceptedPlan]);
|
}, [isMember, room, fetchIdeas, fetchAcceptedPlan]);
|
||||||
|
|
||||||
|
// Check for expired contracts on load
|
||||||
|
useEffect(() => {
|
||||||
|
const p = getCachedProfile();
|
||||||
|
if (!isMember || !p) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.pending?.length) setPendingContracts(data.pending);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})();
|
||||||
|
}, [isMember]);
|
||||||
|
|
||||||
|
// Browser notification timer for active contract
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeContract?.endTime) return;
|
||||||
|
const end = new Date(activeContract.endTime).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const ms = end - now;
|
||||||
|
if (ms <= 0) return;
|
||||||
|
|
||||||
|
if (typeof Notification !== "undefined" && Notification.permission === "default") {
|
||||||
|
Notification.requestPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
|
||||||
|
const n = new Notification("周末契约到期", {
|
||||||
|
body: "你的周末契约已结束,完成了吗?",
|
||||||
|
icon: "/icon-192x192.png",
|
||||||
|
});
|
||||||
|
n.onclick = () => { window.focus(); n.close(); };
|
||||||
|
}
|
||||||
|
// Refresh pending contracts
|
||||||
|
const p = getCachedProfile();
|
||||||
|
if (p) {
|
||||||
|
fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, ms);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [activeContract?.endTime]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMember && inputRef.current) {
|
if (isMember && inputRef.current) {
|
||||||
const t = setTimeout(() => inputRef.current?.focus(), 300);
|
const t = setTimeout(() => inputRef.current?.focus(), 300);
|
||||||
@@ -505,6 +562,28 @@ export default function BlindboxRoomPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Active contract indicator */}
|
||||||
|
{activeContract && phase !== "plan_reveal" && (
|
||||||
|
<motion.button
|
||||||
|
className="mt-3 flex w-full max-w-sm items-center gap-2 rounded-xl bg-purple-600/10 px-3 py-2 ring-1 ring-purple-500/20 transition-colors active:bg-purple-600/20"
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
onClick={() => {
|
||||||
|
setPlanId(activeContract.id);
|
||||||
|
setPlanDays(activeContract.days);
|
||||||
|
setPlanAccepted(true);
|
||||||
|
setPhase("plan_reveal");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCheck size={14} className="shrink-0 text-purple-400" />
|
||||||
|
<span className="text-xs font-medium text-purple-300">契约进行中</span>
|
||||||
|
<span className="text-[10px] text-purple-400/50">
|
||||||
|
{activeContract.days.map((d) => d.date).join(" + ")}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={12} className="ml-auto text-purple-400/40" />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Invite panel */}
|
{/* Invite panel */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showInvite && (
|
{showInvite && (
|
||||||
@@ -808,13 +887,26 @@ export default function BlindboxRoomPage() {
|
|||||||
fireConfetti();
|
fireConfetti();
|
||||||
if (planId && profile) {
|
if (planId && profile) {
|
||||||
try {
|
try {
|
||||||
await fetch("/api/blindbox/plan", {
|
const res = await fetch("/api/blindbox/plan", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ planId, userId: profile.id }),
|
body: JSON.stringify({ planId, userId: profile.id, action: "accept" }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setActiveContract({
|
||||||
|
id: planId,
|
||||||
|
days: planDays,
|
||||||
|
endTime: data.endTime ?? null,
|
||||||
});
|
});
|
||||||
} catch { /* best-effort */ }
|
} catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
|
toast.show("契约已接受!");
|
||||||
|
timersRef.current.push(setTimeout(() => {
|
||||||
|
setPhase("pool");
|
||||||
|
setPlanId(null);
|
||||||
|
setPlanDays([]);
|
||||||
|
setPlanAccepted(false);
|
||||||
|
}, 1500));
|
||||||
}}
|
}}
|
||||||
onRegenerate={() => {
|
onRegenerate={() => {
|
||||||
setPhase("time_select");
|
setPhase("time_select");
|
||||||
@@ -914,6 +1006,18 @@ export default function BlindboxRoomPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="h-8 shrink-0" />
|
<div className="h-8 shrink-0" />
|
||||||
|
|
||||||
|
{/* Contract expiration check modal */}
|
||||||
|
{pendingContracts.length > 0 && profile && (
|
||||||
|
<ContractCompletionModal
|
||||||
|
contracts={pendingContracts}
|
||||||
|
userId={profile.id}
|
||||||
|
onDone={() => {
|
||||||
|
setPendingContracts([]);
|
||||||
|
setActiveContract(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Zap, Gift, Clock, ChevronRight } from "lucide-react";
|
import { Zap, Gift, Clock, ChevronRight, Trophy } from "lucide-react";
|
||||||
import BrandLogo from "@/components/BrandLogo";
|
import BrandLogo from "@/components/BrandLogo";
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
@@ -144,6 +144,25 @@ export default function LandingPage() {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Achievements entry */}
|
||||||
|
<motion.button
|
||||||
|
onClick={() => router.push("/achievements")}
|
||||||
|
className="mt-6 flex w-full max-w-sm items-center gap-3 rounded-xl bg-surface px-4 py-3 ring-1 ring-border transition-colors active:bg-elevated"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-600/15">
|
||||||
|
<Trophy size={16} className="text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="text-sm font-semibold text-heading">成就墙</p>
|
||||||
|
<p className="text-[10px] text-muted">查看决策记录和契约成就</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={14} className="text-muted" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mt-auto pt-10 text-center text-[10px] font-medium tracking-widest text-muted"
|
className="mt-auto pt-10 text-center text-[10px] font-medium tracking-widest text-muted"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Zap,
|
Zap,
|
||||||
|
Trophy,
|
||||||
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/Input";
|
||||||
@@ -485,13 +487,26 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Achievements link */}
|
||||||
|
<Card animated className="mt-4" delay={0.15}>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/achievements")}
|
||||||
|
className="flex w-full items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trophy size={15} className="text-amber-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-secondary">成就墙</h3>
|
||||||
|
<span className="text-[10px] text-dim">决策记录 · 契约成就</span>
|
||||||
|
<ChevronRight size={14} className="ml-auto text-muted" />
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<ProfileHistoryCard
|
<ProfileHistoryCard
|
||||||
history={history}
|
history={history}
|
||||||
loading={historyLoading}
|
loading={historyLoading}
|
||||||
open={showHistory}
|
open={showHistory}
|
||||||
onToggle={() => setShowHistory((v) => !v)}
|
onToggle={() => setShowHistory((v) => !v)}
|
||||||
onEmpty={() => router.push("/blindbox")}
|
onEmpty={() => router.push("/blindbox")}
|
||||||
delay={0.15}
|
delay={0.2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProfileFavoritesCard
|
<ProfileFavoritesCard
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -113,3 +113,14 @@ export interface WeekendPlanData {
|
|||||||
items: PlanItem[];
|
items: PlanItem[];
|
||||||
summary: string;
|
summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContractRecord {
|
||||||
|
id: string;
|
||||||
|
status: "completed" | "expired";
|
||||||
|
roomName: string;
|
||||||
|
roomCode: string;
|
||||||
|
date: string;
|
||||||
|
dayCount: number;
|
||||||
|
activities: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user