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:
@@ -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/`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user