refactor: 提取 Button 组件,统一按钮变体、尺寸和加载状态

新增 Button.tsx 支持 5 种变体(primary/secondary/danger/ghost/purple)、
3 种尺寸(sm/md/lg)、pill/rounded 形状及内置 loading 状态,
替换 8 个文件中 16 处重复的按钮样板代码。
This commit is contained in:
2026-02-26 18:39:14 +08:00
parent 19edcaeeb5
commit 455b9e04d8
9 changed files with 178 additions and 115 deletions
+16 -14
View File
@@ -23,6 +23,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 Button from "@/components/Button";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
import { BlindboxRoomSkeleton } from "@/components/Skeleton"; import { BlindboxRoomSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types"; import type { UserProfile } from "@/types";
@@ -536,14 +537,15 @@ export default function BlindboxRoomPage() {
> >
<Package size={40} className="text-purple-400/50" strokeWidth={1.5} /> <Package size={40} className="text-purple-400/50" strokeWidth={1.5} />
<p className="text-sm text-tertiary"></p> <p className="text-sm text-tertiary"></p>
<button <Button
onClick={handleJoinRoom} onClick={handleJoinRoom}
disabled={joiningRoom} variant="purple"
className="flex h-11 items-center gap-2 rounded-xl bg-purple-600 px-6 text-sm font-bold text-white transition-colors hover:bg-purple-500 disabled:opacity-50" size="lg"
loading={joiningRoom}
icon={<LogIn size={16} />}
> >
{joiningRoom ? <Loader2 size={16} className="animate-spin" /> : <LogIn size={16} />}
</button> </Button>
</motion.div> </motion.div>
) : ( ) : (
<> <>
@@ -717,21 +719,21 @@ export default function BlindboxRoomPage() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<motion.button <Button
onClick={() => setShowShareCard(true)} onClick={() => setShowShareCard(true)}
className="flex h-10 items-center gap-2 rounded-full bg-purple-600 px-5 text-xs font-bold text-white shadow-lg shadow-purple-900/30 transition-colors hover:bg-purple-500" variant="purple"
whileTap={{ scale: 0.96 }} shape="pill"
icon={<Share2 size={14} />}
> >
<Share2 size={14} />
</motion.button> </Button>
<motion.button <Button
onClick={() => { setPhase("pool"); setRevealedIdea(null); setShowShareCard(false); }} onClick={() => { setPhase("pool"); setRevealedIdea(null); setShowShareCard(false); }}
className="flex h-10 items-center gap-2 rounded-full bg-surface px-5 text-xs font-semibold text-muted ring-1 ring-border transition-colors hover:bg-elevated" variant="secondary"
whileTap={{ scale: 0.96 }} shape="pill"
> >
</motion.button> </Button>
</div> </div>
</motion.div> </motion.div>
)} )}
+25 -21
View File
@@ -10,11 +10,11 @@ import {
LogIn, LogIn,
Users, Users,
Sparkles, Sparkles,
Loader2,
ChevronRight, ChevronRight,
} 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 Button from "@/components/Button";
import { BlindboxListSkeleton } from "@/components/Skeleton"; import { BlindboxListSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types"; import type { UserProfile } from "@/types";
@@ -256,14 +256,15 @@ export default function BlindboxLobbyPage() {
maxLength={30} maxLength={30}
className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600" className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
/> />
<button <Button
onClick={handleCreate} onClick={handleCreate}
disabled={creating} variant="purple"
className="flex h-11 items-center gap-1.5 rounded-xl bg-purple-600 px-4 text-sm font-bold text-white transition-colors hover:bg-purple-500 disabled:opacity-50" size="lg"
loading={creating}
icon={<Plus size={16} />}
> >
{creating ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />}
</button> </Button>
</div> </div>
{/* Join alternative */} {/* Join alternative */}
@@ -286,14 +287,16 @@ export default function BlindboxLobbyPage() {
maxLength={6} maxLength={6}
className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600" className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
/> />
<button <Button
onClick={handleJoin} onClick={handleJoin}
disabled={joining || joinCode.trim().length < 6} variant="secondary"
className="flex h-11 items-center gap-1.5 rounded-xl bg-surface px-4 text-sm font-semibold text-secondary ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-40" size="lg"
disabled={joinCode.trim().length < 6}
loading={joining}
icon={<LogIn size={16} />}
> >
{joining ? <Loader2 size={16} className="animate-spin" /> : <LogIn size={16} />}
</button> </Button>
</div> </div>
{error && ( {error && (
@@ -330,14 +333,14 @@ export default function BlindboxLobbyPage() {
maxLength={30} maxLength={30}
className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600" className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
/> />
<button <Button
onClick={handleCreate} onClick={handleCreate}
disabled={creating} variant="purple"
className="flex h-10 items-center gap-1.5 rounded-xl bg-purple-600 px-4 text-xs font-bold text-white transition-colors hover:bg-purple-500 disabled:opacity-50" loading={creating}
icon={<Plus size={14} />}
> >
{creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
</button> </Button>
</div> </div>
{/* Join row */} {/* Join row */}
@@ -354,14 +357,15 @@ export default function BlindboxLobbyPage() {
maxLength={6} maxLength={6}
className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:font-sans placeholder:tracking-normal placeholder:text-dim focus:ring-2 focus:ring-purple-600" className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:font-sans placeholder:tracking-normal placeholder:text-dim focus:ring-2 focus:ring-purple-600"
/> />
<button <Button
onClick={handleJoin} onClick={handleJoin}
disabled={joining || joinCode.trim().length < 6} variant="secondary"
className="flex h-10 items-center gap-1.5 rounded-xl bg-surface px-4 text-xs font-semibold text-secondary ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-40" disabled={joinCode.trim().length < 6}
loading={joining}
icon={<LogIn size={14} />}
> >
{joining ? <Loader2 size={14} className="animate-spin" /> : <LogIn size={14} />}
</button> </Button>
</div> </div>
{error && ( {error && (
+5 -12
View File
@@ -3,6 +3,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { AlertTriangle, RotateCcw, Home } from "lucide-react"; import { AlertTriangle, RotateCcw, Home } from "lucide-react";
import Button from "@/components/Button";
export default function Error({ export default function Error({
error, error,
@@ -37,20 +38,12 @@ export default function Error({
</p> </p>
<div className="mt-8 flex gap-3"> <div className="mt-8 flex gap-3">
<button <Button onClick={reset} variant="danger" icon={<RotateCcw size={15} />}>
onClick={reset}
className="flex items-center gap-1.5 rounded-xl bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-rose-500"
>
<RotateCcw size={15} />
</button> </Button>
<a <Button onClick={() => window.location.href = "/"} variant="secondary" icon={<Home size={15} />}>
href="/"
className="flex items-center gap-1.5 rounded-xl bg-surface px-5 py-2.5 text-sm font-semibold text-secondary ring-1 ring-border transition-colors hover:bg-elevated"
>
<Home size={15} />
</a> </Button>
</div> </div>
</motion.div> </motion.div>
</div> </div>
+10 -18
View File
@@ -9,11 +9,11 @@ import {
Heart, Heart,
Sparkles, Sparkles,
ChevronRight, ChevronRight,
Loader2,
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 { Skeleton, SkeletonCircle } from "@/components/Skeleton";
import Button from "@/components/Button";
import { getSceneConfig } from "@/lib/sceneConfig"; import { getSceneConfig } from "@/lib/sceneConfig";
import type { SceneType } from "@/types"; import type { SceneType } from "@/types";
@@ -93,12 +93,9 @@ export default function InvitePage() {
<p className="text-center text-sm text-muted"> <p className="text-center text-sm text-muted">
</p> </p>
<button <Button onClick={() => router.push("/")} className="mt-2">
onClick={() => router.push("/")}
className="mt-2 rounded-xl bg-accent px-6 py-2.5 text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover"
>
</button> </Button>
</div> </div>
); );
} }
@@ -194,20 +191,15 @@ export default function InvitePage() {
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }} transition={{ duration: 0.5, delay: 0.3 }}
> >
<button <Button
onClick={handleJoin} onClick={handleJoin}
disabled={joining} size="lg"
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-accent text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover disabled:opacity-50" fullWidth
loading={joining}
loadingText="加入中..."
> >
{joining ? (
<> </Button>
<Loader2 size={18} className="animate-spin" />
...
</>
) : (
"加入房间"
)}
</button>
</motion.div> </motion.div>
</div> </div>
); );
+3 -5
View File
@@ -6,6 +6,7 @@ import TopNav from "@/components/TopNav";
import SwipeDeck from "@/components/SwipeDeck"; import SwipeDeck from "@/components/SwipeDeck";
import { SwipeDeckSkeleton } from "@/components/Skeleton"; import { SwipeDeckSkeleton } from "@/components/Skeleton";
import LeaveConfirmModal from "@/components/LeaveConfirmModal"; import LeaveConfirmModal from "@/components/LeaveConfirmModal";
import Button from "@/components/Button";
import { useRoomPolling } from "@/hooks/useRoomPolling"; import { useRoomPolling } from "@/hooks/useRoomPolling";
import { getUserId } from "@/lib/userId"; import { getUserId } from "@/lib/userId";
import { getSceneConfig } from "@/lib/sceneConfig"; import { getSceneConfig } from "@/lib/sceneConfig";
@@ -96,12 +97,9 @@ export default function RoomPage() {
<p className="text-4xl">🍜</p> <p className="text-4xl">🍜</p>
<p className="text-base font-semibold text-secondary"></p> <p className="text-base font-semibold text-secondary"></p>
<p className="text-sm text-muted"> 24 </p> <p className="text-sm text-muted"> 24 </p>
<button <Button onClick={() => router.push("/")}>
onClick={() => router.push("/")}
className="mt-2 h-10 rounded-xl bg-accent px-6 text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover"
>
</button> </Button>
</div> </div>
); );
} }
+10 -15
View File
@@ -2,11 +2,12 @@
import { useState } from "react"; import { useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { X, Loader2, Eye, EyeOff } from "lucide-react"; import { X, Eye, EyeOff } from "lucide-react";
import { AVATARS } from "@/lib/avatars"; import { AVATARS } from "@/lib/avatars";
import { setCachedProfile } from "@/lib/userId"; import { setCachedProfile } from "@/lib/userId";
import type { UserProfile } from "@/types"; import type { UserProfile } from "@/types";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import Button from "@/components/Button";
type Tab = "login" | "register"; type Tab = "login" | "register";
@@ -232,22 +233,16 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
</motion.p> </motion.p>
)} )}
<button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={loading} size="lg"
className="mt-5 flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-accent text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover disabled:opacity-50" fullWidth
loading={loading}
loadingText={tab === "login" ? "登录中..." : "注册中..."}
className="mt-5"
> >
{loading ? ( {tab === "login" ? "登录" : "注册"}
<> </Button>
<Loader2 size={16} className="animate-spin" />
{tab === "login" ? "登录中..." : "注册中..."}
</>
) : tab === "login" ? (
"登录"
) : (
"注册"
)}
</button>
</Modal> </Modal>
); );
} }
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { type ComponentProps, type ReactNode } from "react";
import { motion } from "framer-motion";
import { Loader2 } from "lucide-react";
const variantStyles = {
primary:
"bg-accent text-white shadow-lg shadow-accent/20 hover:bg-accent-hover disabled:opacity-50",
secondary:
"bg-surface text-secondary ring-1 ring-border hover:bg-elevated disabled:opacity-40",
danger:
"bg-rose-600 text-white hover:bg-rose-500 disabled:opacity-50",
ghost:
"text-muted hover:text-secondary hover:bg-elevated disabled:opacity-50",
purple:
"bg-purple-600 text-white hover:bg-purple-500 disabled:opacity-50",
} as const;
const sizeStyles = {
sm: "h-8 px-3 text-xs gap-1",
md: "h-10 px-4 text-sm gap-1.5",
lg: "h-11 px-6 text-sm font-bold gap-2",
} as const;
const spinnerSize = { sm: 13, md: 15, lg: 18 } as const;
interface ButtonProps
extends Omit<ComponentProps<typeof motion.button>, "ref" | "children"> {
variant?: keyof typeof variantStyles;
size?: "sm" | "md" | "lg";
shape?: "rounded" | "pill";
loading?: boolean;
loadingText?: string;
icon?: ReactNode;
fullWidth?: boolean;
children?: ReactNode;
}
export default function Button({
variant = "primary",
size = "md",
shape = "rounded",
loading = false,
loadingText,
icon,
fullWidth = false,
className = "",
disabled,
children,
...rest
}: ButtonProps) {
const base = "flex items-center justify-center font-semibold transition-colors";
const shapeClass = shape === "pill" ? "rounded-full" : "rounded-xl";
return (
<motion.button
className={`${base} ${variantStyles[variant]} ${sizeStyles[size]} ${shapeClass} ${fullWidth ? "w-full" : ""} ${className}`}
disabled={loading || disabled}
whileTap={{ scale: 0.95 }}
{...rest}
>
{loading ? (
<Loader2 size={spinnerSize[size]} className="animate-spin" />
) : icon ? (
icon
) : null}
{loading && loadingText ? loadingText : children}
</motion.button>
);
}
+26 -22
View File
@@ -34,6 +34,7 @@ import { isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal"; import ShareCardModal from "@/components/ShareCardModal";
import RestaurantImage from "@/components/RestaurantImage"; import RestaurantImage from "@/components/RestaurantImage";
import AuthModal from "@/components/AuthModal"; import AuthModal from "@/components/AuthModal";
import Button from "@/components/Button";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
interface MatchResultProps { interface MatchResultProps {
@@ -107,24 +108,26 @@ function NoMatchResult({
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.55 }} transition={{ delay: 0.55 }}
> >
<motion.button <Button
onClick={onReset} onClick={onReset}
disabled={resetting} shape="pill"
className="flex items-center justify-center gap-2 rounded-full bg-accent px-8 py-3 text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover disabled:opacity-50" loading={resetting}
whileTap={{ scale: 0.95 }} loadingText="重置中..."
icon={<RotateCcw size={15} />}
className="px-8 py-3"
> >
<RotateCcw size={15} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"} </Button>
</motion.button>
<motion.button <Button
onClick={() => router.push("/")} onClick={() => router.push("/")}
className="flex items-center justify-center gap-2 rounded-full bg-surface px-8 py-3 text-sm font-bold text-muted ring-1 ring-border transition-colors hover:bg-elevated" variant="secondary"
whileTap={{ scale: 0.95 }} shape="pill"
icon={<Home size={15} />}
className="px-8 py-3"
> >
<Home size={15} />
</motion.button> </Button>
</motion.div> </motion.div>
</motion.div> </motion.div>
); );
@@ -487,14 +490,15 @@ export default function MatchResult({
</motion.a> </motion.a>
)} )}
<motion.button <Button
onClick={handleOpenShareCard} onClick={handleOpenShareCard}
className="flex items-center justify-center gap-2 rounded-full bg-surface px-8 py-3 text-sm font-bold text-secondary ring-1 ring-border transition-colors hover:bg-elevated" variant="secondary"
whileTap={{ scale: 0.95 }} shape="pill"
icon={<Share2 size={15} />}
className="px-8 py-3"
> >
<Share2 size={15} />
</motion.button> </Button>
</motion.div> </motion.div>
{/* Registration nudge */} {/* Registration nudge */}
@@ -511,14 +515,14 @@ export default function MatchResult({
<p className="mt-1 text-xs text-muted"> <p className="mt-1 text-xs text-muted">
+ 10 + 10
</p> </p>
<motion.button <Button
onClick={() => setShowAuth(true)} onClick={() => setShowAuth(true)}
className="mt-3 flex h-10 w-full items-center justify-center gap-2 rounded-xl bg-accent text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover" fullWidth
whileTap={{ scale: 0.95 }} icon={<UserPlus size={15} />}
className="mt-3"
> >
<UserPlus size={15} />
</motion.button> </Button>
</motion.div> </motion.div>
)} )}
+12 -8
View File
@@ -6,6 +6,7 @@ import { X, Copy, Share2, QrCode } from "lucide-react";
import type { SceneType } from "@/types"; import type { SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig"; import { getSceneConfig } from "@/lib/sceneConfig";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import Button from "@/components/Button";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
interface QrInviteModalProps { interface QrInviteModalProps {
@@ -92,20 +93,23 @@ export default function QrInviteModal({
</div> </div>
<div className="mt-5 flex w-full gap-2.5"> <div className="mt-5 flex w-full gap-2.5">
<button <Button
onClick={handleCopy} onClick={handleCopy}
className="flex h-11 flex-1 items-center justify-center gap-1.5 rounded-xl bg-elevated text-sm font-semibold text-secondary ring-1 ring-border transition-colors active:bg-subtle" variant="secondary"
size="lg"
icon={<Copy size={15} />}
className="flex-1"
> >
<Copy size={15} />
</button> </Button>
<button <Button
onClick={handleShare} onClick={handleShare}
className="flex h-11 flex-1 items-center justify-center gap-1.5 rounded-xl bg-accent text-sm font-semibold text-white shadow-lg shadow-accent/20 transition-colors active:bg-accent-hover" size="lg"
icon={<Share2 size={15} />}
className="flex-1"
> >
<Share2 size={15} />
</button> </Button>
</div> </div>
</div> </div>
</Modal> </Modal>