refactor(P2/P3): 完成全部7批重构 — 模块化、SSE退避、无障碍、Zod校验、Server组件、Room关系化
批次A:重命名 + 路由拆分 - store.ts → roomRepository.ts,更新全部 import - blindbox/plan/route.ts 精简为薄路由,业务逻辑抽取到 planActions.ts / planQueries.ts 批次B:blindboxPlanGen.ts 拆分(710行 → src/lib/plan/) - agentPlan.ts:Agent 工具调用与系统提示 - legacyPlan.ts:非 Agent 备用生成逻辑 - ideaSelection.ts:Idea 筛选与 Slot 映射 - transitEnrichment.ts:交通信息查询与填充 - index.ts:runPlanGeneration 主入口 批次C:SSE 连接稳定性 - useRoomPolling.ts 加入指数退避重连(上限60s,含Jitter) - plan/stream/route.ts 添加30s心跳 + abort信号清理 批次D:无障碍修复 - Modal:role=dialog、aria-modal、aria-labelledby - AuthModal:aria-label关闭按钮、tablist/tab/aria-selected - PlanItemEditModal、QrInviteModal:补全aria-label - BlindboxPlan:图标按钮aria-label 批次E:Zod 引入 - src/lib/schemas/ai.ts:AI返回值 Schema(IdeaTagsSchema等5个) - src/lib/schemas/requests.ts:请求体 Schema - ai.ts 手工验证替换为 Zod safeParse 批次F:Server Components - achievements/page.tsx → Server Component + AchievementsClient.tsx - profile/page.tsx → Server Component + ProfileClient.tsx 批次G:Room 关系化模型 - prisma/schema.prisma:新增 RoomMember、RoomRestaurant、RoomLike、RoomSwipe 4张表 - migration:20260302010000_room_relational_model - roomRepository.ts 完整重写(关系查询+应用锁) - buildRoomStatus.ts 适配关系查询 测试:全部329个用例通过,修复68个因auth mock缺失导致的测试失败
This commit is contained in:
+16
-248
@@ -1,252 +1,20 @@
|
||||
"use client";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifyToken } from "@/lib/auth";
|
||||
import AchievementsClient from "./AchievementsClient";
|
||||
|
||||
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 { useAchievements } from "@/hooks/useAchievements";
|
||||
import type { Restaurant } from "@/types";
|
||||
export default async function AchievementsPage() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("nw_token")?.value;
|
||||
|
||||
type Tab = "decisions" | "contracts";
|
||||
if (!token) redirect("/");
|
||||
|
||||
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 [tab, setTab] = useState<Tab>("decisions");
|
||||
const [userId, setUserId] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRegistered()) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
const p = getCachedProfile();
|
||||
if (p) setUserId(p.id);
|
||||
}, [router]);
|
||||
|
||||
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
|
||||
|
||||
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}
|
||||
image="/empty-no-record.png"
|
||||
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}
|
||||
image="/empty-no-room.png"
|
||||
title="还没有契约记录"
|
||||
subtitle="完成或过期的契约会在这里显示"
|
||||
color="purple"
|
||||
/>
|
||||
) : (
|
||||
contracts.map((c) => (
|
||||
<ContractHistoryItem key={c.id} record={c} />
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="h-8 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await verifyToken(token);
|
||||
} catch {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return <AchievementsClient initialUserId={userId} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user