ui: 骨架屏替代全部页面级加载 spinner

- 新增 Skeleton 组件库:Skeleton、SkeletonCircle 基础元素 + 5 个业务骨架
  (SwipeDeck、ProfileCard、RecordItem、BlindboxRoom、BlindboxList、RoomCard)
- 替换 room、profile、blindbox 列表、blindbox 房间、invite 5 个页面的加载态
- 替换 profile 历史记录 / 收藏列表的内联加载 spinner
- 更新 project-conventions.mdc:新增 Loading States 规范,
  要求页面级和列表级加载必须使用骨架屏
This commit is contained in:
2026-02-26 16:11:01 +08:00
parent 798b883250
commit b2b18327cc
7 changed files with 200 additions and 29 deletions
+8
View File
@@ -35,6 +35,14 @@ alwaysApply: true
- `overflow-y-auto scrollbar-none` for scrollable pages - `overflow-y-auto scrollbar-none` for scrollable pages
- Extract reusable UI patterns into shared components (modals, cards, empty states) - Extract reusable UI patterns into shared components (modals, cards, empty states)
## Loading States
- **Page-level and list-level loading**: always use skeleton screens (`src/components/Skeleton.tsx`), never bare spinners
- Skeleton shape should mimic the actual content layout (cards, list items, avatars, text lines)
- Compose page skeletons from reusable skeleton primitives: `Skeleton`, `SkeletonCircle`, `RecordItemSkeleton`, `RoomCardSkeleton`, etc.
- **Button-level loading** (submit, save, join): keep using inline `Loader2` spinner — skeleton screens don't apply to buttons
- When adding a new page or async data section, always include a skeleton loading state
## API Routes ## API Routes
- Located in `src/app/api/` - Located in `src/app/api/`
+2 -5
View File
@@ -20,6 +20,7 @@ import {
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId"; import { getCachedProfile, isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal"; import ShareCardModal from "@/components/ShareCardModal";
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types"; import type { UserProfile } from "@/types";
interface RoomInfo { interface RoomInfo {
@@ -285,11 +286,7 @@ export default function BlindboxRoomPage() {
}; };
if (pageLoading) { if (pageLoading) {
return ( return <BlindboxRoomSkeleton />;
<div className="flex min-h-dvh items-center justify-center bg-background">
<Loader2 size={24} className="animate-spin text-purple-400" />
</div>
);
} }
if (!room) return null; if (!room) return null;
+2 -10
View File
@@ -15,6 +15,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { getCachedProfile, isRegistered } from "@/lib/userId"; import { getCachedProfile, isRegistered } from "@/lib/userId";
import AuthModal from "@/components/AuthModal"; import AuthModal from "@/components/AuthModal";
import { BlindboxListSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types"; import type { UserProfile } from "@/types";
interface RoomSummary { interface RoomSummary {
@@ -216,16 +217,7 @@ export default function BlindboxLobbyPage() {
</p> </p>
</motion.div> </motion.div>
) : loading ? ( ) : loading ? (
/* ============ Loading ============ */ <BlindboxListSkeleton />
<motion.div
key="loading"
className="mt-20 flex flex-col items-center gap-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<Loader2 size={24} className="animate-spin text-purple-400" />
<p className="text-xs text-muted">...</p>
</motion.div>
) : rooms.length === 0 ? ( ) : rooms.length === 0 ? (
/* ============ Layer 2: Logged in, no rooms — Create first ============ */ /* ============ Layer 2: Logged in, no rooms — Create first ============ */
<motion.div <motion.div
+18 -2
View File
@@ -13,6 +13,7 @@ import {
Coffee, Coffee,
} from "lucide-react"; } from "lucide-react";
import { getUserId } from "@/lib/userId"; import { getUserId } from "@/lib/userId";
import { Skeleton, SkeletonCircle } from "@/components/Skeleton";
import { getSceneConfig } from "@/lib/sceneConfig"; import { getSceneConfig } from "@/lib/sceneConfig";
import type { SceneType } from "@/types"; import type { SceneType } from "@/types";
@@ -61,8 +62,23 @@ export default function InvitePage() {
if (status === "loading") { if (status === "loading") {
return ( return (
<div className="flex min-h-dvh flex-col items-center justify-center bg-background"> <div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-subtle border-t-accent" /> <Skeleton className="h-16 w-16 rounded-2xl" />
<Skeleton className="mt-5 h-8 w-40" />
<Skeleton className="mt-2 h-4 w-24" />
<div className="mt-8 w-full max-w-xs rounded-2xl bg-surface px-6 py-5 ring-1 ring-border">
<div className="flex flex-col items-center gap-3">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-7 w-24 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<div className="mt-6 flex gap-3">
<SkeletonCircle className="h-10 w-10" />
<SkeletonCircle className="h-10 w-10" />
<SkeletonCircle className="h-10 w-10" />
</div>
<Skeleton className="mt-8 h-12 w-full max-w-xs rounded-xl" />
</div> </div>
); );
} }
+27 -6
View File
@@ -25,6 +25,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import EmptyState from "@/components/EmptyState"; import EmptyState from "@/components/EmptyState";
import RestaurantImage from "@/components/RestaurantImage"; import RestaurantImage from "@/components/RestaurantImage";
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId"; import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
import { getAvatarBg, AVATARS } from "@/lib/avatars"; import { getAvatarBg, AVATARS } from "@/lib/avatars";
import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types"; import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types";
@@ -256,8 +257,26 @@ export default function ProfilePage() {
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-background"> <div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
<Loader2 size={24} className="animate-spin text-muted" /> <nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
<div className="h-8 w-8" />
<h1 className="flex-1 text-base font-bold text-white"></h1>
</nav>
<div className="mx-auto max-w-sm px-5">
<ProfileCardSkeleton />
<div className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border">
<div className="flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
</div>
<div className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border">
<div className="flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
</div>
</div>
</div> </div>
); );
} }
@@ -534,8 +553,9 @@ export default function ProfilePage() {
className="overflow-hidden" className="overflow-hidden"
> >
{historyLoading ? ( {historyLoading ? (
<div className="flex justify-center py-6"> <div className="mt-3 flex flex-col gap-2">
<Loader2 size={18} className="animate-spin text-muted" /> <RecordItemSkeleton />
<RecordItemSkeleton />
</div> </div>
) : history.length === 0 ? ( ) : history.length === 0 ? (
<EmptyState <EmptyState
@@ -616,8 +636,9 @@ export default function ProfilePage() {
className="overflow-hidden" className="overflow-hidden"
> >
{favLoading ? ( {favLoading ? (
<div className="flex justify-center py-6"> <div className="mt-3 flex flex-col gap-2">
<Loader2 size={18} className="animate-spin text-muted" /> <RecordItemSkeleton />
<RecordItemSkeleton />
</div> </div>
) : favorites.length === 0 ? ( ) : favorites.length === 0 ? (
<EmptyState <EmptyState
+2 -6
View File
@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import TopNav from "@/components/TopNav"; import TopNav from "@/components/TopNav";
import SwipeDeck from "@/components/SwipeDeck"; import SwipeDeck from "@/components/SwipeDeck";
import { SwipeDeckSkeleton } from "@/components/Skeleton";
import LeaveConfirmModal from "@/components/LeaveConfirmModal"; import LeaveConfirmModal from "@/components/LeaveConfirmModal";
import { useRoomPolling } from "@/hooks/useRoomPolling"; import { useRoomPolling } from "@/hooks/useRoomPolling";
import { getUserId } from "@/lib/userId"; import { getUserId } from "@/lib/userId";
@@ -111,12 +112,7 @@ export default function RoomPage() {
const sceneConfig = getSceneConfig(scene); const sceneConfig = getSceneConfig(scene);
if (!ready) { if (!ready) {
return ( return <SwipeDeckSkeleton />;
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-background">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-subtle border-t-accent" />
<p className="text-sm text-muted">...</p>
</div>
);
} }
return ( return (
+141
View File
@@ -0,0 +1,141 @@
"use client";
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-lg bg-elevated ${className}`}
/>
);
}
export function SkeletonCircle({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-full bg-elevated ${className}`}
/>
);
}
export function RoomCardSkeleton() {
return (
<div className="flex w-full items-center gap-3 rounded-2xl bg-surface p-4 ring-1 ring-border">
<Skeleton className="h-11 w-11 shrink-0 rounded-xl" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-36" />
</div>
<Skeleton className="h-3 w-10" />
</div>
);
}
export function ProfileCardSkeleton() {
return (
<div className="rounded-2xl bg-surface p-4 ring-1 ring-border">
<div className="flex items-center gap-4">
<Skeleton className="h-14 w-14 rounded-2xl" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3 w-40" />
</div>
</div>
</div>
);
}
export function RecordItemSkeleton() {
return (
<div className="flex gap-3 rounded-xl bg-elevated p-2.5">
<Skeleton className="h-12 w-12 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col justify-center gap-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
);
}
export function SwipeDeckSkeleton() {
return (
<div className="flex h-dvh flex-col bg-background">
<nav className="flex h-14 items-center px-4">
<div className="flex items-center gap-1.5">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-7 w-28 rounded-full" />
<Skeleton className="h-7 w-16 rounded-full" />
</div>
</nav>
<div className="flex flex-1 items-center justify-center px-4">
<div className="h-[60vh] w-full max-w-sm">
<div className="h-full overflow-hidden rounded-2xl bg-surface ring-1 ring-border">
<Skeleton className="h-[58%] w-full rounded-none" />
<div className="flex flex-col gap-3 px-5 py-4">
<Skeleton className="h-5 w-40" />
<div className="flex gap-3">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-10" />
</div>
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-32" />
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center gap-4 pb-5">
<SkeletonCircle className="h-13 w-13" />
<SkeletonCircle className="h-13 w-13" />
</div>
</div>
);
}
export function BlindboxRoomSkeleton() {
return (
<div className="flex min-h-dvh flex-col items-center bg-background px-5 py-6">
<div className="flex w-full max-w-sm items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex flex-1 flex-col gap-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-2.5 w-16" />
</div>
<div className="flex -space-x-1.5">
<SkeletonCircle className="h-7 w-7 ring-2 ring-background" />
<SkeletonCircle className="h-7 w-7 ring-2 ring-background" />
</div>
<Skeleton className="h-8 w-8 rounded-full" />
</div>
<Skeleton className="mt-10 h-36 w-36 rounded-2xl" />
<Skeleton className="mt-6 h-5 w-32" />
<Skeleton className="mt-2 h-3 w-48" />
<div className="mt-8 w-full max-w-sm space-y-3">
<Skeleton className="h-12 w-full rounded-xl" />
<Skeleton className="h-12 w-full rounded-xl" />
</div>
</div>
);
}
export function BlindboxListSkeleton() {
return (
<div className="mt-6 flex w-full max-w-sm flex-col gap-3">
<div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-xl" />
<Skeleton className="h-10 w-20 rounded-xl" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-xl" />
<Skeleton className="h-10 w-20 rounded-xl" />
</div>
<div className="mt-3 flex flex-col gap-3">
<RoomCardSkeleton />
<RoomCardSkeleton />
<RoomCardSkeleton />
</div>
</div>
);
}