diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc
index 695b350..2eaf669 100644
--- a/.cursor/rules/project-conventions.mdc
+++ b/.cursor/rules/project-conventions.mdc
@@ -35,6 +35,14 @@ alwaysApply: true
- `overflow-y-auto scrollbar-none` for scrollable pages
- 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
- Located in `src/app/api/`
diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx
index cfc9ff0..25e5f93 100644
--- a/src/app/blindbox/[code]/page.tsx
+++ b/src/app/blindbox/[code]/page.tsx
@@ -20,6 +20,7 @@ import {
import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
+import { BlindboxRoomSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types";
interface RoomInfo {
@@ -285,11 +286,7 @@ export default function BlindboxRoomPage() {
};
if (pageLoading) {
- return (
-
-
-
- );
+ return ;
}
if (!room) return null;
diff --git a/src/app/blindbox/page.tsx b/src/app/blindbox/page.tsx
index 0eb27b6..86f5c52 100644
--- a/src/app/blindbox/page.tsx
+++ b/src/app/blindbox/page.tsx
@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import AuthModal from "@/components/AuthModal";
+import { BlindboxListSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types";
interface RoomSummary {
@@ -216,16 +217,7 @@ export default function BlindboxLobbyPage() {
) : loading ? (
- /* ============ Loading ============ */
-
-
- 加载中...
-
+
) : rooms.length === 0 ? (
/* ============ Layer 2: Logged in, no rooms — Create first ============ */
-
+
);
}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
index ba1915d..ad22db1 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/profile/page.tsx
@@ -25,6 +25,7 @@ import {
} from "lucide-react";
import EmptyState from "@/components/EmptyState";
import RestaurantImage from "@/components/RestaurantImage";
+import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
import { getAvatarBg, AVATARS } from "@/lib/avatars";
import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types";
@@ -256,8 +257,26 @@ export default function ProfilePage() {
if (loading) {
return (
-
-
+
);
}
@@ -534,8 +553,9 @@ export default function ProfilePage() {
className="overflow-hidden"
>
{historyLoading ? (
-
-
+
+
+
) : history.length === 0 ? (
{favLoading ? (
-
-
+
+
+
) : favorites.length === 0 ? (
-
- 正在加载数据...
-
- );
+ return ;
}
return (
diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx
new file mode 100644
index 0000000..34e5015
--- /dev/null
+++ b/src/components/Skeleton.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+interface SkeletonProps {
+ className?: string;
+}
+
+export function Skeleton({ className = "" }: SkeletonProps) {
+ return (
+
+ );
+}
+
+export function SkeletonCircle({ className = "" }: SkeletonProps) {
+ return (
+
+ );
+}
+
+export function RoomCardSkeleton() {
+ return (
+
+ );
+}
+
+export function ProfileCardSkeleton() {
+ return (
+
+ );
+}
+
+export function RecordItemSkeleton() {
+ return (
+
+ );
+}
+
+export function SwipeDeckSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export function BlindboxRoomSkeleton() {
+ return (
+
+ );
+}
+
+export function BlindboxListSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}