Skip to Content
@anker-in/headless-ui 2.0 is released 🎉

大转盘抽奖Wheel Lottery

组件分类: 活动营销组件 | 适用场景: 营销活动、节日促销、用户增长 | Figma: 查看设计稿 

大转盘抽奖(Wheel Lottery) 是一个交互式抽奖组件,支持8奖品配置异步抽奖API多模态系统机会获取方式。提供完整的抽奖流程管理和用户互动体验。

核心功能: 8奖品位配置、异步抽奖接口、中奖模态框、奖品展示模态框、规则说明模态框、机会获取方式、命令式API、响应式布局、动画效果

使用场景: 节日营销活动、新品推广抽奖、用户注册奖励、会员福利活动、电商促销活动

WheelLottery (大转盘抽奖)

大转盘抽奖互动组件,支持自定义奖品和抽奖动画【🔄 开发中】

加载中...
当前视口: 1920px × 600px场景: 浅色主题-8奖品
打开链接

功能特性

WheelLottery 是一个功能丰富的交互式大转盘抽奖组件,提供完整的抽奖体验和灵活的配置选项:

  • 8奖品配置 - 固定8个奖品槽位,支持实物奖品、优惠券、“再接再厉”等多种奖品类型
  • 响应式设计 - 支持5个断点的完美适配(mobile, tablet, laptop, desktop, lg-desktop)
  • 主题切换 - 内置 Light/Dark 双主题,自动适配品牌色调
  • 异步抽奖API - 支持1-30秒的异步抽奖请求,持续旋转动画直到结果返回
  • 三阶段动画 - 平滑的慢速启动 → 快速旋转 → 减速停止动画系统
  • 中奖/未中奖模态 - 自动识别”try-again”奖品并展示相应模态框
  • 5大模态系统 - Rules(规则)、My Rewards(我的奖品)、Winner(中奖)、Error(未中奖)、Share(分享)
  • 机会获取方式 - 可配置多种获取抽奖机会的方式(分享、积分兑换、邀请好友等)
  • 登录状态控制 - 未登录时显示遮罩层,引导用户登录后参与
  • 剩余机会管理 - 实时显示可用抽奖次数和总次数
  • 中奖信息滚动 - 展示实时中奖用户信息,营造热烈氛围
  • 命令式API - 通过ref提供丰富的命令式方法,支持外部触发模态框显示/隐藏
  • 奖品排名徽章 - 支持 1st/2nd/3rd 排名标识展示
  • 社交媒体分享 - 内置6个主流社交平台分享支持(Facebook、Twitter、Instagram、LinkedIn、TikTok、YouTube)
  • 我的奖品列表 - 展示用户历史中奖记录,支持优惠码复制
  • 再接再厉处理 - 自动识别”try-again”奖品,显示鼓励文案而非中奖模态
  • 灵活的事件回调 - 提供 onSpinStart、onSpinEnd、onSpinError 等完整生命周期回调
  • 自定义文案 - 所有模态框标题、按钮文案、提示文本均可自定义

Props 参数

主要参数

参数类型默认值必需说明
prizesPrize[]-奖品列表,必须包含8个奖品
userDataUserData-用户数据(登录状态、可用次数、总次数、中奖记录等)
chanceMethodsChanceMethod[][]获取抽奖机会的方式列表
theme'light' | 'dark''light'主题模式
spinDurationnumber4000旋转动画持续时间(毫秒)
onSpinStart() => Promise<string>-开始抽奖回调,需返回中奖的 prizeKey
onSpinEnd(prize: Prize) => void-抽奖结束回调(不包括”try-again”)
onSpinError(error: Error) => void-抽奖错误回调
winnerModalConfigWinnerModalConfig-中奖模态框配置
errorModalConfigErrorModalConfig-未中奖/错误模态框配置
rulesModalConfigRulesModalConfig-规则模态框配置
myRewardsModalConfigMyRewardsModalConfig-我的奖品模态框配置
shareModalConfigShareModalConfig-分享模态框配置
winningInfoConfigWinningInfoConfig-中奖滚动信息配置
loginPromptConfigLoginPromptConfig-登录提示配置
classNamestring-自定义样式类名
refReact.Ref<WheelLotteryHandle>-命令式API引用

Prize 奖品配置

interface Prize { prizeKey: string // 奖品唯一标识(不要使用'id') name: string // 奖品名称 image: Img // 奖品图片 { url: string, alt?: string } rank?: '1st' | '2nd' | '3rd' // 奖品排名(显示徽章) price?: string // 奖品价格/价值 } interface Img { url: string // 图片URL alt?: string // 无障碍描述 }

UserData 用户数据

interface UserData { isLoggedIn: boolean // 是否已登录 availableChances: number // 可用抽奖次数 totalChances?: number // 总抽奖次数(可选,用于显示进度) wonPrizes?: Array<Prize & { timestamp?: number }> // 中奖记录列表 email?: string // 用户邮箱(用于领奖) }

ChanceMethod 机会获取方式

interface ChanceMethod { methodKey: string // 方法唯一标识 type: 'share' | 'spend-points' | 'refer' | string // 方法类型 title: string // 方法标题 description: string // 方法描述 buttonText?: string // 按钮文案 onClick: () => void // 点击回调 onLoginRequired: () => void // 需要登录时的回调 disabled?: boolean // 是否禁用 status?: 'pending' | 'completed' | 'used' // 状态 loading?: boolean // 加载状态 openShareModal?: boolean // 是否打开分享模态框(type为'share'时) }

WinnerModalConfig 中奖模态框配置

interface WinnerModalConfig { title?: string // 模态框标题 confirmText?: string // 确认按钮文案 onConfirm?: () => void // 确认按钮回调 showPrizeDetails?: boolean // 是否显示奖品详情 }

ErrorModalConfig 未中奖/错误模态框配置

interface ErrorModalConfig { title?: string // 模态框标题 message?: string // 提示消息 confirmText?: string // 确认按钮文案 onConfirm?: () => void // 确认按钮回调 }

RulesModalConfig 规则模态框配置

interface RulesModalConfig { rulesData: Array<{ title: string // 规则标题 content: string | string[] // 规则内容(支持字符串或字符串数组) }> rulesText?: string // "Rules"按钮文案 rulesTitle?: string // 模态框标题 }

MyRewardsModalConfig 我的奖品模态框配置

interface MyRewardsModalConfig { myRewardsText?: string // "My Rewards"按钮文案 myRewardsTitle?: string // 模态框标题 codeText?: string // 优惠码标签文案 copyText?: string // 复制按钮文案 copiedText?: string // 已复制提示文案 prizeText?: string // 中奖时间标签文案 emptyText?: string // 空状态提示文案 }

ShareModalConfig 分享模态框配置

interface ShareModalConfig { title?: string // 模态框标题 description?: string // 分享描述 shareUrl?: string // 分享链接 platforms?: Array<'facebook' | 'twitter' | 'instagram' | 'linkedin' | 'tiktok' | 'youtube'> // 支持的平台 onShare?: (platform: string) => void // 分享回调 onClose?: () => void // 关闭回调 }

WinningInfoConfig 中奖信息滚动配置

interface WinningInfoConfig { show?: boolean // 是否显示 data?: Array<{ username: string // 用户名(自动脱敏) prizeName: string // 奖品名称 timestamp?: number // 中奖时间戳 }> scrollSpeed?: number // 滚动速度(毫秒) }

LoginPromptConfig 登录提示配置

interface LoginPromptConfig { title?: string // 提示标题 description?: string // 提示描述 loginButtonText?: string // 登录按钮文案 onLogin?: () => void // 登录按钮回调 }

WheelLotteryHandle 命令式API

interface WheelLotteryHandle { showError: (config?: ErrorModalConfig) => void // 显示错误模态框 hideError: () => void // 隐藏错误模态框 showNoWin: (config?: ErrorModalConfig) => void // 显示未中奖模态框 hideNoWin: () => void // 隐藏未中奖模态框 showWinner: (prize: Prize, config?: WinnerModalConfig) => void // 显示中奖模态框 hideWinner: () => void // 隐藏中奖模态框 showRules: () => void // 显示规则模态框 hideRules: () => void // 隐藏规则模态框 showRewards: () => void // 显示我的奖品模态框 hideRewards: () => void // 隐藏我的奖品模态框 showShare: (config?: ShareModalConfig) => void // 显示分享模态框 hideShare: () => void // 隐藏分享模态框 hideAllModals: () => void // 隐藏所有模态框 }

类型定义

完整类型定义

import { WheelLottery } from '@anker-in/headless-ui/biz' // 图片类型 interface Img { url: string alt?: string } // 奖品接口 interface Prize { prizeKey: string // 奖品唯一标识(不要使用'id') name: string // 奖品名称 image: Img // 奖品图片 rank?: '1st' | '2nd' | '3rd' // 奖品排名 price?: string // 奖品价格 } // 用户数据接口 interface UserData { isLoggedIn: boolean availableChances: number totalChances?: number wonPrizes?: Array<Prize & { timestamp?: number }> email?: string } // 机会获取方式接口 interface ChanceMethod { methodKey: string type: 'share' | 'spend-points' | 'refer' | string title: string description: string buttonText?: string onClick: () => void onLoginRequired: () => void disabled?: boolean status?: 'pending' | 'completed' | 'used' loading?: boolean openShareModal?: boolean } // 中奖模态框配置 interface WinnerModalConfig { title?: string confirmText?: string onConfirm?: () => void showPrizeDetails?: boolean } // 错误/未中奖模态框配置 interface ErrorModalConfig { title?: string message?: string confirmText?: string onConfirm?: () => void } // 规则模态框配置 interface RulesModalConfig { rulesData: Array<{ title: string content: string | string[] }> rulesText?: string rulesTitle?: string } // 我的奖品模态框配置 interface MyRewardsModalConfig { myRewardsText?: string myRewardsTitle?: string codeText?: string copyText?: string copiedText?: string prizeText?: string emptyText?: string } // 分享模态框配置 interface ShareModalConfig { title?: string description?: string shareUrl?: string platforms?: Array<'facebook' | 'twitter' | 'instagram' | 'linkedin' | 'tiktok' | 'youtube'> onShare?: (platform: string) => void onClose?: () => void } // 中奖信息配置 interface WinningInfoConfig { show?: boolean data?: Array<{ username: string prizeName: string timestamp?: number }> scrollSpeed?: number } // 登录提示配置 interface LoginPromptConfig { title?: string description?: string loginButtonText?: string onLogin?: () => void } // 命令式API接口 interface WheelLotteryHandle { showError: (config?: ErrorModalConfig) => void hideError: () => void showNoWin: (config?: ErrorModalConfig) => void hideNoWin: () => void showWinner: (prize: Prize, config?: WinnerModalConfig) => void hideWinner: () => void showRules: () => void hideRules: () => void showRewards: () => void hideRewards: () => void showShare: (config?: ShareModalConfig) => void hideShare: () => void hideAllModals: () => void } // 组件Props interface WheelLotteryProps { prizes: Prize[] userData: UserData chanceMethods?: ChanceMethod[] theme?: 'light' | 'dark' spinDuration?: number onSpinStart: () => Promise<string> onSpinEnd?: (prize: Prize) => void onSpinError?: (error: Error) => void winnerModalConfig?: WinnerModalConfig errorModalConfig?: ErrorModalConfig rulesModalConfig?: RulesModalConfig myRewardsModalConfig?: MyRewardsModalConfig shareModalConfig?: ShareModalConfig winningInfoConfig?: WinningInfoConfig loginPromptConfig?: LoginPromptConfig className?: string ref?: React.Ref<WheelLotteryHandle> }

使用示例

1. 基础抽奖轮盘

最简单的8奖品抽奖配置:

'use client' import { useState } from 'react' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function BasicLotteryExample() { const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, }) const prizes = [ { prizeKey: 'prize-001', name: 'Anker Prime Charger', image: { url: 'https://images.unsplash.com/photo-1591290619762-eba3a9180451?w=400&q=80', alt: 'Anker Prime Charger', }, rank: '1st' as const, price: '$79.99', }, { prizeKey: 'prize-002', name: 'Power Bank 20000mAh', image: { url: 'https://images.unsplash.com/photo-1609091839311-d5365f9ff1c5?w=400&q=80', alt: 'Power Bank', }, rank: '2nd' as const, price: '$49.99', }, { prizeKey: 'prize-003', name: 'USB-C Cable', image: { url: 'https://images.unsplash.com/photo-1625948515291-69613efd103f?w=400&q=80', alt: 'USB-C Cable', }, rank: '3rd' as const, price: '$19.99', }, { prizeKey: 'prize-004', name: '$30 Coupon', image: { url: 'https://images.unsplash.com/photo-1607083206869-4c7672e72a8a?w=400&q=80', alt: '$30 Coupon', }, price: '$30.00', }, { prizeKey: 'prize-005', name: '$20 Coupon', image: { url: 'https://images.unsplash.com/photo-1607083206325-caf1edba7a0f?w=400&q=80', alt: '$20 Coupon', }, price: '$20.00', }, { prizeKey: 'prize-006', name: '$10 Coupon', image: { url: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?w=400&q=80', alt: '$10 Coupon', }, price: '$10.00', }, { prizeKey: 'prize-007', name: '$5 Coupon', image: { url: 'https://images.unsplash.com/photo-1556742031-c6961e8560b0?w=400&q=80', alt: '$5 Coupon', }, price: '$5.00', }, { prizeKey: 'try-again', name: 'Try Again', image: { url: 'https://images.unsplash.com/photo-1620121478247-ec786b9be2fa?w=400&q=80', alt: 'Try Again', }, }, ] const handleSpinStart = async () => { // 模拟API调用 await new Promise((resolve) => setTimeout(resolve, 2000)) // 返回随机奖品 const randomIndex = Math.floor(Math.random() * prizes.length) return prizes[randomIndex].prizeKey } const handleSpinEnd = (prize: any) => { console.log('Won:', prize.name) setUserData((prev) => ({ ...prev, availableChances: prev.availableChances - 1, })) } return ( <WheelLottery prizes={prizes} userData={userData} onSpinStart={handleSpinStart} onSpinEnd={handleSpinEnd} /> ) }

2. 完整配置示例

包含所有模态框和机会获取方式的完整配置:

'use client' import { useState } from 'react' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function FullConfigExample() { const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, wonPrizes: [], }) const prizes = [ // ... 8个奖品配置(同上) ] const chanceMethods = [ { methodKey: 'share', type: 'share' as const, title: 'Share on Social Media', description: 'Share this event to get +1 chance', buttonText: 'Share Now', onClick: () => { console.log('Share clicked') }, onLoginRequired: () => { console.log('Login required') }, openShareModal: true, }, { methodKey: 'points', type: 'spend-points' as const, title: 'Spend 100 Points', description: 'Use your reward points', buttonText: 'Redeem Now', onClick: () => { setUserData((prev) => ({ ...prev, availableChances: prev.availableChances + 1, })) }, onLoginRequired: () => { console.log('Login required') }, }, { methodKey: 'refer', type: 'refer' as const, title: 'Refer a Friend', description: 'Invite friends to join', buttonText: 'Invite Now', onClick: () => { console.log('Refer clicked') }, onLoginRequired: () => { console.log('Login required') }, }, ] const handleSpinStart = async () => { await new Promise((resolve) => setTimeout(resolve, 2000)) const randomIndex = Math.floor(Math.random() * prizes.length) return prizes[randomIndex].prizeKey } const handleSpinEnd = (prize: any) => { setUserData((prev) => ({ ...prev, availableChances: prev.availableChances - 1, wonPrizes: [ ...prev.wonPrizes, { ...prize, timestamp: Date.now() }, ], })) } return ( <WheelLottery prizes={prizes} userData={userData} chanceMethods={chanceMethods} theme="light" spinDuration={4000} onSpinStart={handleSpinStart} onSpinEnd={handleSpinEnd} winnerModalConfig={{ title: 'Congratulations!', confirmText: 'Claim Prize', }} errorModalConfig={{ title: 'Better Luck Next Time', message: 'Keep trying to win!', confirmText: 'Try Again', }} rulesModalConfig={{ rulesData: [ { title: 'How to Participate', content: 'Click GO to spin the wheel', }, { title: 'Winning Rules', content: [ 'Each user gets 5 chances per day', 'Prizes are random', 'Coupons expire in 30 days', ], }, ], rulesText: 'Rules', rulesTitle: 'Lottery Rules', }} myRewardsModalConfig={{ myRewardsText: 'My Rewards', myRewardsTitle: 'My Prizes', codeText: 'CODE:', copyText: 'COPY', copiedText: 'COPIED', prizeText: 'Won at:', emptyText: 'No prizes yet', }} /> ) }

3. 异步API抽奖

处理长时间API请求的抽奖场景:

'use client' import { useState } from 'react' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function AsyncAPIExample() { const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, }) const prizes = [ // ... 8个奖品配置 ] const handleSpinStart = async () => { try { // 真实API调用 const response = await fetch('/api/lottery/spin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: 'user123' }), }) if (!response.ok) { throw new Error('Lottery API failed') } const data = await response.json() // 返回后端返回的中奖prizeKey return data.prizeKey } catch (error) { console.error('Spin error:', error) throw error } } const handleSpinEnd = (prize: any) => { console.log('Won prize:', prize.name) // 更新本地状态 setUserData((prev) => ({ ...prev, availableChances: prev.availableChances - 1, })) // 可选:同步到后端 fetch('/api/lottery/claim', { method: 'POST', body: JSON.stringify({ prizeKey: prize.prizeKey }), }) } const handleSpinError = (error: Error) => { console.error('Lottery error:', error) // 显示错误提示 alert('抽奖失败,请稍后重试') } return ( <WheelLottery prizes={prizes} userData={userData} spinDuration={6000} onSpinStart={handleSpinStart} onSpinEnd={handleSpinEnd} onSpinError={handleSpinError} /> ) }

4. Try-Again未中奖处理

正确处理”再接再厉”奖品:

'use client' import { useState } from 'react' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function TryAgainExample() { const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, }) const prizes = [ // ... 前7个实物/优惠券奖品 { prizeKey: 'try-again', // 关键:使用'try-again'作为prizeKey name: 'Try Again', image: { url: 'https://images.unsplash.com/photo-1620121478247-ec786b9be2fa?w=400&q=80', alt: 'Try Again', }, }, ] const handleSpinStart = async () => { await new Promise((resolve) => setTimeout(resolve, 2000)) // 假设50%概率未中奖 const isWin = Math.random() > 0.5 if (isWin) { // 返回实物奖品 const winIndex = Math.floor(Math.random() * 7) return prizes[winIndex].prizeKey } else { // 返回'try-again' return 'try-again' } } const handleSpinEnd = (prize: any) => { // 注意:当prizeKey为'try-again'时,onSpinEnd不会被调用 // 只有真正中奖时才会触发 console.log('Real prize won:', prize.name) setUserData((prev) => ({ ...prev, availableChances: prev.availableChances - 1, })) } return ( <WheelLottery prizes={prizes} userData={userData} onSpinStart={handleSpinStart} onSpinEnd={handleSpinEnd} errorModalConfig={{ title: 'Better Luck Next Time!', message: 'Don\'t give up! You still have chances left.', confirmText: 'Try Again', }} /> ) }

5. 命令式API控制

使用ref外部控制模态框:

'use client' import { useState, useRef } from 'react' import { WheelLottery, WheelLotteryHandle } from '@anker-in/headless-ui/biz' export default function ImperativeAPIExample() { const wheelRef = useRef<WheelLotteryHandle>(null) const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, }) const prizes = [ // ... 8个奖品配置 ] const handleSpinStart = async () => { await new Promise((resolve) => setTimeout(resolve, 2000)) const randomIndex = Math.floor(Math.random() * prizes.length) return prizes[randomIndex].prizeKey } // 外部控制按钮 const handleShowRules = () => { wheelRef.current?.showRules() } const handleShowRewards = () => { wheelRef.current?.showRewards() } const handleShowCustomError = () => { wheelRef.current?.showError({ title: 'System Maintenance', message: 'Lottery is temporarily unavailable', confirmText: 'OK', }) } const handleForceShowWinner = () => { // 强制显示中奖模态(用于测试或特殊场景) wheelRef.current?.showWinner(prizes[0], { title: 'Special Reward!', confirmText: 'Claim Now', }) } const handleHideAll = () => { wheelRef.current?.hideAllModals() } return ( <div> <div className="mb-4 flex gap-2"> <button onClick={handleShowRules}>Show Rules</button> <button onClick={handleShowRewards}>Show Rewards</button> <button onClick={handleShowCustomError}>Show Error</button> <button onClick={handleForceShowWinner}>Force Winner</button> <button onClick={handleHideAll}>Hide All</button> </div> <WheelLottery ref={wheelRef} prizes={prizes} userData={userData} onSpinStart={handleSpinStart} /> </div> ) }

6. 登录状态控制

未登录时显示遮罩层引导登录:

'use client' import { useState } from 'react' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function LoginControlExample() { const [userData, setUserData] = useState({ isLoggedIn: false, // 初始未登录 availableChances: 0, totalChances: 5, }) const prizes = [ // ... 8个奖品配置 ] const handleLogin = () => { // 模拟登录 console.log('Redirecting to login page...') // 登录成功后更新状态 setTimeout(() => { setUserData({ isLoggedIn: true, availableChances: 5, totalChances: 5, }) }, 1000) } const handleSpinStart = async () => { await new Promise((resolve) => setTimeout(resolve, 2000)) const randomIndex = Math.floor(Math.random() * prizes.length) return prizes[randomIndex].prizeKey } return ( <WheelLottery prizes={prizes} userData={userData} onSpinStart={handleSpinStart} loginPromptConfig={{ title: 'Login Required', description: 'Please login to participate in the lottery', loginButtonText: 'Login Now', onLogin: handleLogin, }} /> ) }

7. 中奖信息滚动

显示实时中奖用户信息:

'use client' import { useState } from 'react' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function WinningInfoExample() { const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, }) const prizes = [ // ... 8个奖品配置 ] // 模拟实时中奖数据 const winningInfo = [ { username: 'john.doe@email.com', // 将自动脱敏为 j***e@email.com prizeName: 'Anker Prime Charger', timestamp: Date.now() - 300000, // 5分钟前 }, { username: 'alice.smith@email.com', prizeName: '$30 Off Coupon', timestamp: Date.now() - 180000, // 3分钟前 }, { username: 'bob.johnson@email.com', prizeName: 'Power Bank 20000mAh', timestamp: Date.now() - 60000, // 1分钟前 }, ] const handleSpinStart = async () => { await new Promise((resolve) => setTimeout(resolve, 2000)) const randomIndex = Math.floor(Math.random() * prizes.length) return prizes[randomIndex].prizeKey } return ( <WheelLottery prizes={prizes} userData={userData} onSpinStart={handleSpinStart} winningInfoConfig={{ show: true, data: winningInfo, scrollSpeed: 3000, // 每3秒滚动一次 }} /> ) }

8. 社交分享模态框

配置社交媒体分享功能:

'use client' import { useState } from 'react' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function ShareModalExample() { const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, }) const prizes = [ // ... 8个奖品配置 ] const chanceMethods = [ { methodKey: 'share', type: 'share' as const, title: 'Share to Get +1 Chance', description: 'Share on social media', buttonText: 'Share Now', onClick: () => { console.log('Opening share modal') }, onLoginRequired: () => { console.log('Login required') }, openShareModal: true, // 关键:启用分享模态框 }, ] const handleSpinStart = async () => { await new Promise((resolve) => setTimeout(resolve, 2000)) const randomIndex = Math.floor(Math.random() * prizes.length) return prizes[randomIndex].prizeKey } const handleShare = (platform: string) => { console.log(`Sharing to ${platform}`) // 分享成功后增加抽奖次数 setUserData((prev) => ({ ...prev, availableChances: prev.availableChances + 1, })) // 调用后端API记录分享 fetch('/api/lottery/share', { method: 'POST', body: JSON.stringify({ platform }), }) } return ( <WheelLottery prizes={prizes} userData={userData} chanceMethods={chanceMethods} onSpinStart={handleSpinStart} shareModalConfig={{ title: 'Share with Friends', description: 'Share this lottery event and get +1 chance', shareUrl: 'https://example.com/lottery', platforms: ['facebook', 'twitter', 'linkedin', 'instagram'], onShare: handleShare, }} /> ) }

9. 暗黑主题

使用暗黑主题模式:

'use client' import { useState } from 'react' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function DarkThemeExample() { const [theme, setTheme] = useState<'light' | 'dark'>('dark') const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, }) const prizes = [ // ... 8个奖品配置 ] const handleSpinStart = async () => { await new Promise((resolve) => setTimeout(resolve, 2000)) const randomIndex = Math.floor(Math.random() * prizes.length) return prizes[randomIndex].prizeKey } return ( <div> <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}> Switch to {theme === 'light' ? 'Dark' : 'Light'} Theme </button> <WheelLottery prizes={prizes} userData={userData} theme={theme} onSpinStart={handleSpinStart} /> </div> ) }

10. 自定义样式

使用自定义样式类:

'use client' import { useState } from 'react' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function CustomStyleExample() { const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, }) const prizes = [ // ... 8个奖品配置 ] const handleSpinStart = async () => { await new Promise((resolve) => setTimeout(resolve, 2000)) const randomIndex = Math.floor(Math.random() * prizes.length) return prizes[randomIndex].prizeKey } return ( <WheelLottery prizes={prizes} userData={userData} onSpinStart={handleSpinStart} className="custom-lottery-wrapper" winnerModalConfig={{ title: 'You Won!', confirmText: 'Claim', }} /> ) }

对应CSS:

.custom-lottery-wrapper { --wheel-primary-color: #ff6b00; --wheel-secondary-color: #ffa500; --wheel-accent-color: #ff4500; max-width: 800px; margin: 0 auto; padding: 2rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 20px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); }

响应式行为

WheelLottery 组件在不同屏幕尺寸下的表现:

移动端 (< 768px)

特性行为
轮盘尺寸300px × 300px
奖品图片40px × 40px
GO按钮80px × 80px
机会获取卡片单列堆叠布局
模态框全屏显示,底部滑入动画
中奖信息滚动隐藏(节省空间)
字体大小14px (奖品名称)
间距紧凑间距 (gap-2)

平板端 (768px - 1024px)

特性行为
轮盘尺寸400px × 400px
奖品图片50px × 50px
GO按钮100px × 100px
机会获取卡片2列网格布局
模态框居中弹窗,max-width: 500px
中奖信息滚动显示在顶部
字体大小16px (奖品名称)
间距标准间距 (gap-4)

桌面端 (≥ 1024px)

特性行为
轮盘尺寸500px × 500px
奖品图片60px × 60px
GO按钮120px × 120px
机会获取卡片3列网格布局
模态框居中弹窗,max-width: 600px
中奖信息滚动显示在顶部
字体大小18px (奖品名称)
间距宽松间距 (gap-6)

响应式断点对照

// Tailwind配置的断点映射 const breakpoints = { mobile: '< 768px', // 移动端 tablet: '768px', // 平板 laptop: '1025px', // 小桌面 desktop: '1440px', // 大桌面 'lg-desktop': '1920px', // 超大屏 } // 组件内部使用 const wheelSize = { mobile: 300, tablet: 400, desktop: 500, }

核心算法解析

1. 旋转动画算法

三阶段旋转动画的实现:

interface SpinAnimation { phase: 'slow' | 'fast' | 'decelerate' currentRotation: number targetRotation: number startTime: number duration: number } /** * 计算旋转角度 * @param prizeIndex 中奖奖品索引(0-7) * @returns 旋转角度(度数) */ function calculateTargetRotation(prizeIndex: number): number { // 8个奖品,每个占45度 const degreesPerPrize = 360 / 8 // 额外旋转3-5圈增加悬念 const extraSpins = 3 + Math.floor(Math.random() * 3) const extraDegrees = extraSpins * 360 // 计算目标角度(指针在顶部12点位置) // 需要让奖品中心对准指针 const targetDegrees = extraDegrees + (360 - prizeIndex * degreesPerPrize) + (degreesPerPrize / 2) // 对准奖品中心 return targetDegrees } /** * 缓动函数:慢速启动 → 快速旋转 → 减速停止 * @param t 当前时间进度(0-1) * @returns 旋转进度(0-1) */ function easeInOutCubic(t: number): number { // 0-0.2: 慢速启动(ease-in) if (t < 0.2) { return (t / 0.2) ** 2 * 0.1 } // 0.2-0.7: 匀速快速旋转 if (t < 0.7) { return 0.1 + ((t - 0.2) / 0.5) * 0.7 } // 0.7-1.0: 减速停止(ease-out) const decelT = (t - 0.7) / 0.3 return 0.8 + (1 - (1 - decelT) ** 3) * 0.2 } /** * 动画帧更新函数 */ function animateWheel(animation: SpinAnimation): void { const now = Date.now() const elapsed = now - animation.startTime const progress = Math.min(elapsed / animation.duration, 1) // 应用缓动函数 const easedProgress = easeInOutCubic(progress) // 计算当前旋转角度 const currentRotation = easedProgress * animation.targetRotation // 更新DOM wheelElement.style.transform = `rotate(${currentRotation}deg)` // 继续动画或结束 if (progress < 1) { requestAnimationFrame(() => animateWheel(animation)) } else { // 动画结束,触发回调 onSpinComplete(prizeIndex) } } // 时间复杂度: O(n), n为动画帧数(约60fps × duration/1000) // 空间复杂度: O(1)

2. 异步抽奖API处理

支持长时间API请求的持续旋转:

/** * 处理异步抽奖请求 * @returns 中奖的prizeKey */ async function handleAsyncSpin(): Promise<string> { // 1. 开始慢速旋转(等待API响应) startSlowSpin() try { // 2. 调用抽奖API(可能需要1-30秒) const prizeKey = await onSpinStart() // 3. API返回后,查找对应的奖品索引 const prizeIndex = prizes.findIndex((p) => p.prizeKey === prizeKey) if (prizeIndex === -1) { throw new Error(`Invalid prizeKey: ${prizeKey}`) } // 4. 计算目标角度 const targetRotation = calculateTargetRotation(prizeIndex) // 5. 开始减速到目标位置 startDecelerateToTarget(targetRotation) return prizeKey } catch (error) { // 6. API失败,停止旋转并显示错误 stopSpin() onSpinError?.(error as Error) throw error } } /** * 慢速持续旋转(等待API) */ function startSlowSpin(): void { let rotation = 0 const slowSpinSpeed = 2 // 每帧2度 const animate = () => { if (!isWaitingForAPI) return rotation = (rotation + slowSpinSpeed) % 360 wheelElement.style.transform = `rotate(${rotation}deg)` requestAnimationFrame(animate) } isWaitingForAPI = true animate() } /** * 从当前位置减速到目标位置 */ function startDecelerateToTarget(targetRotation: number): void { isWaitingForAPI = false const currentRotation = getCurrentRotation() const remainingRotation = targetRotation - currentRotation animateToTarget(currentRotation, targetRotation, 2000) } // 时间复杂度: O(1) 查找 + O(n) 动画帧 // 空间复杂度: O(1)

3. Try-Again检测算法

自动识别”再接再厉”奖品:

/** * 检测是否为"try-again"奖品 * @param prizeKey 中奖的prizeKey * @returns 是否为try-again */ function isTryAgainPrize(prizeKey: string): boolean { return prizeKey === 'try-again' } /** * 处理抽奖结果 */ async function handleSpinResult(): Promise<void> { try { // 1. 调用异步抽奖API const prizeKey = await handleAsyncSpin() // 2. 查找对应的奖品对象 const prize = prizes.find((p) => p.prizeKey === prizeKey) if (!prize) { throw new Error(`Prize not found: ${prizeKey}`) } // 3. 等待动画完成 await waitForSpinComplete() // 4. 检测是否为try-again if (isTryAgainPrize(prizeKey)) { // 显示未中奖模态框 showErrorModal({ title: errorModalConfig?.title || 'Better Luck Next Time', message: errorModalConfig?.message || 'Keep trying!', confirmText: errorModalConfig?.confirmText || 'Try Again', }) // 不触发onSpinEnd回调(因为未真正中奖) // 但仍然扣除抽奖次数 updateUserChances(-1) } else { // 显示中奖模态框 showWinnerModal(prize, winnerModalConfig) // 触发中奖回调 onSpinEnd?.(prize) // 添加到中奖记录 addToWonPrizes(prize) // 扣除抽奖次数 updateUserChances(-1) } } catch (error) { onSpinError?.(error as Error) } } /** * 更新用户抽奖次数 */ function updateUserChances(delta: number): void { setUserData((prev) => ({ ...prev, availableChances: Math.max(0, prev.availableChances + delta), })) } /** * 添加到中奖记录 */ function addToWonPrizes(prize: Prize): void { setUserData((prev) => ({ ...prev, wonPrizes: [ ...(prev.wonPrizes || []), { ...prize, timestamp: Date.now(), }, ], })) } // 时间复杂度: O(n), n为奖品数量(最多8) // 空间复杂度: O(m), m为中奖记录数量

4. 模态框管理算法

统一管理5个模态框的显示/隐藏:

interface ModalState { winner: { visible: boolean; prize?: Prize; config?: WinnerModalConfig } error: { visible: boolean; config?: ErrorModalConfig } rules: { visible: boolean } rewards: { visible: boolean } share: { visible: boolean; config?: ShareModalConfig } } /** * 模态框状态管理 */ const [modalState, setModalState] = useState<ModalState>({ winner: { visible: false }, error: { visible: false }, rules: { visible: false }, rewards: { visible: false }, share: { visible: false }, }) /** * 显示指定模态框(自动隐藏其他模态框) */ function showModal( type: keyof ModalState, data?: any ): void { setModalState((prev) => { // 创建新状态:所有模态框都隐藏 const newState: ModalState = { winner: { visible: false }, error: { visible: false }, rules: { visible: false }, rewards: { visible: false }, share: { visible: false }, } // 只显示指定的模态框 newState[type] = { visible: true, ...data } return newState }) } /** * 隐藏指定模态框 */ function hideModal(type: keyof ModalState): void { setModalState((prev) => ({ ...prev, [type]: { visible: false }, })) } /** * 隐藏所有模态框 */ function hideAllModals(): void { setModalState({ winner: { visible: false }, error: { visible: false }, rules: { visible: false }, rewards: { visible: false }, share: { visible: false }, }) } /** * 命令式API实现 */ useImperativeHandle(ref, () => ({ showError: (config?: ErrorModalConfig) => { showModal('error', { config }) }, hideError: () => { hideModal('error') }, showNoWin: (config?: ErrorModalConfig) => { // NoWin使用同一个error模态框 showModal('error', { config }) }, hideNoWin: () => { hideModal('error') }, showWinner: (prize: Prize, config?: WinnerModalConfig) => { showModal('winner', { prize, config }) }, hideWinner: () => { hideModal('winner') }, showRules: () => { showModal('rules') }, hideRules: () => { hideModal('rules') }, showRewards: () => { showModal('rewards') }, hideRewards: () => { hideModal('rewards') }, showShare: (config?: ShareModalConfig) => { showModal('share', { config }) }, hideShare: () => { hideModal('share') }, hideAllModals, })) // 时间复杂度: O(1) 所有操作 // 空间复杂度: O(1)

5. 社交分享算法

多平台分享URL生成:

interface SocialPlatform { name: string urlTemplate: string icon: string } const SOCIAL_PLATFORMS: Record<string, SocialPlatform> = { facebook: { name: 'Facebook', urlTemplate: 'https://www.facebook.com/sharer/sharer.php?u={url}', icon: '📘', }, twitter: { name: 'Twitter', urlTemplate: 'https://twitter.com/intent/tweet?url={url}&text={text}', icon: '🐦', }, linkedin: { name: 'LinkedIn', urlTemplate: 'https://www.linkedin.com/sharing/share-offsite/?url={url}', icon: '💼', }, instagram: { name: 'Instagram', urlTemplate: 'https://www.instagram.com/?url={url}', // Instagram不支持URL分享,仅作示例 icon: '📷', }, tiktok: { name: 'TikTok', urlTemplate: 'https://www.tiktok.com/share?url={url}', icon: '🎵', }, youtube: { name: 'YouTube', urlTemplate: 'https://www.youtube.com/share?url={url}', icon: '📹', }, } /** * 生成分享URL * @param platform 社交平台 * @param shareUrl 要分享的URL * @param text 分享文案 * @returns 完整的分享链接 */ function generateShareUrl( platform: string, shareUrl: string, text?: string ): string { const platformConfig = SOCIAL_PLATFORMS[platform] if (!platformConfig) { throw new Error(`Unsupported platform: ${platform}`) } let url = platformConfig.urlTemplate // 替换URL占位符 url = url.replace('{url}', encodeURIComponent(shareUrl)) // 替换文案占位符 if (text) { url = url.replace('{text}', encodeURIComponent(text)) } return url } /** * 打开分享窗口 */ function openShareWindow(platform: string, shareConfig: ShareModalConfig): void { const shareUrl = shareConfig.shareUrl || window.location.href const text = shareConfig.description || 'Check out this lottery!' // 生成分享URL const url = generateShareUrl(platform, shareUrl, text) // 打开弹窗(居中显示) const width = 600 const height = 400 const left = (window.innerWidth - width) / 2 const top = (window.innerHeight - height) / 2 const shareWindow = window.open( url, `share-${platform}`, `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes` ) // 触发分享回调 shareConfig.onShare?.(platform) // 监听分享窗口关闭 if (shareWindow) { const checkClosed = setInterval(() => { if (shareWindow.closed) { clearInterval(checkClosed) console.log(`Share window for ${platform} closed`) } }, 500) } } // 时间复杂度: O(1) // 空间复杂度: O(1)

6. 用户名脱敏算法

保护用户隐私的用户名脱敏:

/** * 脱敏用户名/邮箱 * @param username 原始用户名 * @returns 脱敏后的用户名 */ function maskUsername(username: string): string { // 1. 检测是否为邮箱格式 const emailRegex = /^(.+)@(.+)$/ const emailMatch = username.match(emailRegex) if (emailMatch) { // 邮箱格式: 保留首尾字符,中间用***替代 const [, localPart, domain] = emailMatch if (localPart.length <= 2) { // 太短,只保留首字符 return `${localPart[0]}***@${domain}` } // 保留首尾字符 const masked = `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}` return masked } // 2. 普通用户名格式 if (username.length <= 3) { // 太短,保留首字符 return `${username[0]}***` } // 保留首尾字符,中间用***替代 return `${username[0]}***${username[username.length - 1]}` } // 示例 console.log(maskUsername('john.doe@email.com')) // j***e@email.com console.log(maskUsername('alice.smith@email.com')) // a***h@email.com console.log(maskUsername('bob')) // b*** console.log(maskUsername('alexander')) // a***r // 时间复杂度: O(n), n为用户名长度 // 空间复杂度: O(n)

7. 奖品排名徽章渲染

动态渲染1st/2nd/3rd排名徽章:

interface RankBadgeConfig { rank: '1st' | '2nd' | '3rd' colors: { bg: string text: string border: string } icon: string } const RANK_CONFIG: Record<string, RankBadgeConfig> = { '1st': { rank: '1st', colors: { bg: 'bg-gradient-to-br from-yellow-400 to-yellow-600', text: 'text-white', border: 'border-yellow-300', }, icon: '👑', }, '2nd': { rank: '2nd', colors: { bg: 'bg-gradient-to-br from-gray-300 to-gray-500', text: 'text-white', border: 'border-gray-200', }, icon: '🥈', }, '3rd': { rank: '3rd', colors: { bg: 'bg-gradient-to-br from-orange-400 to-orange-600', text: 'text-white', border: 'border-orange-300', }, icon: '🥉', }, } /** * 渲染排名徽章组件 */ function RankBadge({ rank }: { rank?: '1st' | '2nd' | '3rd' }) { if (!rank) return null const config = RANK_CONFIG[rank] return ( <div className={` absolute -top-2 -right-2 px-2 py-1 rounded-full border-2 ${config.colors.bg} ${config.colors.text} ${config.colors.border} text-xs font-bold shadow-lg flex items-center gap-1 animate-bounce `} > <span>{config.icon}</span> <span>{rank}</span> </div> ) } /** * 在奖品卡片中使用 */ function PrizeCard({ prize }: { prize: Prize }) { return ( <div className="relative"> {/* 奖品图片 */} <img src={prize.image.url} alt={prize.image.alt} /> {/* 排名徽章 */} <RankBadge rank={prize.rank} /> {/* 奖品信息 */} <div> <h3>{prize.name}</h3> <p>{prize.price}</p> </div> </div> ) } // 时间复杂度: O(1) // 空间复杂度: O(1)

设计规范

轮盘设计规范

布局结构:

  • 轮盘为正圆形,8个奖品扇形均匀分布(每个45度)
  • 中央GO按钮直径为轮盘直径的24%(desktop: 120px / 500px)
  • 指针位于顶部12点钟位置,固定不动
  • 奖品图片放置在扇形中心位置

颜色系统:

// Light主题 const lightTheme = { wheelBg: '#FFFFFF', wheelBorder: '#E5E7EB', sectorEven: '#FFF7ED', // 偶数扇形(橙色调) sectorOdd: '#FEFCE8', // 奇数扇形(黄色调) pointer: '#EF4444', // 指针红色 goButton: { bg: 'linear-gradient(135deg, #F59E0B 0%, #EF4444 100%)', text: '#FFFFFF', shadow: 'rgba(239, 68, 68, 0.3)', }, } // Dark主题 const darkTheme = { wheelBg: '#1F2937', wheelBorder: '#374151', sectorEven: '#422006', // 深橙色调 sectorOdd: '#713F12', // 深黄色调 pointer: '#DC2626', goButton: { bg: 'linear-gradient(135deg, #D97706 0%, #DC2626 100%)', text: '#FFFFFF', shadow: 'rgba(220, 38, 38, 0.5)', }, }

奖品扇形设计:

  • 扇形边框: 1px solid border color
  • 奖品图片: 圆形,居中显示
  • 奖品名称: 12px/14px,最多显示2行,超出省略
  • 奖品价格: 10px/12px,半透明显示

动画时序:

const animationTimings = { slowPhase: '0-20%', // 慢速启动,时长0.8秒 fastPhase: '20-70%', // 快速旋转,时长2秒 decelPhase: '70-100%', // 减速停止,时长1.2秒 totalDuration: 4000, // 总时长4秒 }

模态框设计规范

通用规范:

  • 遮罩层背景: rgba(0, 0, 0, 0.6)
  • 模态框圆角: 16px
  • 模态框阴影: 0 20px 60px rgba(0, 0, 0, 0.3)
  • 标题字体: 24px/28px, 粗体
  • 内容字体: 16px/20px, 常规
  • 按钮高度: 48px
  • 按钮圆角: 12px

Winner模态框:

  • 背景渐变: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%)
  • 奖品图片尺寸: 120px × 120px
  • 烟花动画: 持续2秒,3个粒子效果
  • 按钮主色: #F59E0B

Error/NoWin模态框:

  • 背景渐变: linear-gradient(135deg, #FEE2E2 0%, #FECACA 100%)
  • 图标: 灰色哭脸或再接再厉图标
  • 按钮主色: #EF4444

Rules模态框:

  • 背景: 纯白色(light) / #1F2937(dark)
  • 规则项间距: 16px
  • 列表项标记: 数字序号
  • 最大高度: 70vh,超出滚动

My Rewards模态框:

  • 空状态插图: 180px × 180px
  • 奖品列表项: 左侧图片64px,右侧信息垂直排列
  • 优惠码复制按钮: 绿色主题 #10B981
  • 已复制提示: 1.5秒淡出动画

Share模态框:

  • 平台图标尺寸: 48px × 48px
  • 图标hover效果: scale(1.1) + 阴影
  • 平台布局: 2列网格(mobile) / 3列网格(desktop)

机会获取卡片设计

卡片规范:

  • 圆角: 12px
  • 边框: 1px solid #E5E7EB
  • hover效果: translateY(-4px) + 阴影增强
  • 图标尺寸: 32px × 32px
  • 标题字体: 18px, 粗体
  • 描述字体: 14px, 灰色
  • 按钮高度: 40px

状态样式:

const statusStyles = { pending: { button: 'bg-blue-500 hover:bg-blue-600', text: 'text-gray-700', }, completed: { button: 'bg-green-500 cursor-not-allowed', text: 'text-green-600', badge: '✓ Completed', }, used: { button: 'bg-gray-400 cursor-not-allowed', text: 'text-gray-500', badge: '✓ Used', }, }

中奖信息滚动设计

滚动规范:

  • 背景: 半透明黑色 rgba(0, 0, 0, 0.8)
  • 文字颜色: 白色
  • 字体大小: 14px
  • 滚动速度: 30px/秒 (可配置)
  • 显示位置: 轮盘上方,宽度与轮盘一致
  • 滚动方向: 从右向左无限循环

信息格式:

const formatWinningInfo = (info: WinningInfo): string => { const maskedName = maskUsername(info.username) const timeAgo = formatTimeAgo(info.timestamp) return `🎉 ${maskedName} won ${info.prizeName} ${timeAgo}` } // 示例输出: "🎉 j***e@email.com won Anker Prime Charger 5 minutes ago"

无障碍性

WheelLottery 组件遵循 WCAG 2.1 AA 标准:

键盘导航

按键功能
Tab聚焦到GO按钮或其他交互元素
Enter / Space激活GO按钮开始抽奖
Esc关闭当前打开的模态框
Tab (模态框内)在模态框内的元素间切换焦点

ARIA属性

// 轮盘容器 <div role="region" aria-label="Lottery Wheel" aria-live="polite" aria-busy={isSpinning} > {/* GO按钮 */} <button aria-label={isSpinning ? 'Spinning...' : 'Start lottery'} aria-disabled={!canSpin} disabled={!canSpin} > GO </button> {/* 奖品列表 */} <div role="list" aria-label="Prize list"> {prizes.map((prize) => ( <div key={prize.prizeKey} role="listitem" aria-label={`${prize.name}, ${prize.price}`} > <img src={prize.image.url} alt={prize.image.alt || prize.name} /> </div> ))} </div> </div> // 模态框 <div role="dialog" aria-modal="true" aria-labelledby="modal-title" aria-describedby="modal-description" > <h2 id="modal-title">Congratulations!</h2> <p id="modal-description">You won {prize.name}</p> </div>

颜色对比度

所有文本与背景的对比度符合WCAG AA标准(最低4.5:1):

// Light主题对比度检查 const contrastRatios = { 'GO按钮文本/背景': '21:1', // ✅ 白色文本 / 橙红渐变 '奖品名称/扇形背景': '7:1', // ✅ 深灰文本 / 浅黄背景 '模态框标题/背景': '12:1', // ✅ 深灰标题 / 白色背景 '机会卡片描述/背景': '5.5:1', // ✅ 灰色描述 / 白色背景 }

屏幕阅读器支持

// 中奖公告(屏幕阅读器可读) <div role="status" aria-live="assertive" aria-atomic="true" className="sr-only" // 视觉隐藏,但可被屏幕阅读器读取 > {lastWinner && `${lastWinner.name} won ${lastWinner.prize}`} </div> // 加载状态公告 <div role="status" aria-live="polite" className="sr-only" > {isSpinning && 'Spinning the wheel, please wait...'} </div>

焦点管理

/** * 模态框打开时,焦点移动到模态框 */ useEffect(() => { if (isModalOpen && modalRef.current) { // 保存之前的焦点元素 const previousFocus = document.activeElement as HTMLElement // 移动焦点到模态框 modalRef.current.focus() // 关闭时恢复焦点 return () => { previousFocus?.focus() } } }, [isModalOpen]) /** * 焦点陷阱(Trap focus within modal) */ function trapFocus(event: KeyboardEvent): void { if (event.key !== 'Tab') return const focusableElements = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) if (!focusableElements || focusableElements.length === 0) return const firstElement = focusableElements[0] as HTMLElement const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement if (event.shiftKey && document.activeElement === firstElement) { // Shift+Tab on first element → focus last element event.preventDefault() lastElement.focus() } else if (!event.shiftKey && document.activeElement === lastElement) { // Tab on last element → focus first element event.preventDefault() firstElement.focus() } }

性能优化

1. 动画性能优化

使用 CSS transformwill-change 优化动画性能:

// 轮盘旋转优化 <div className="wheel-container" style={{ transform: `rotate(${rotation}deg)`, willChange: isSpinning ? 'transform' : 'auto', }} > {/* 奖品列表 */} </div> // CSS样式 .wheel-container { /* 启用硬件加速 */ transform: translateZ(0); /* 使用GPU渲染 */ backface-visibility: hidden; /* 平滑插值 */ transform-origin: center center; }

2. 状态管理优化

使用 useMemouseCallback 避免不必要的重渲染:

// 缓存奖品索引查找 const prizeIndexMap = useMemo(() => { const map = new Map<string, number>() prizes.forEach((prize, index) => { map.set(prize.prizeKey, index) }) return map }, [prizes]) // 缓存回调函数 const handleSpinClick = useCallback(async () => { if (!canSpin || isSpinning) return setIsSpinning(true) try { const prizeKey = await onSpinStart() const prizeIndex = prizeIndexMap.get(prizeKey) if (prizeIndex === undefined) { throw new Error(`Invalid prizeKey: ${prizeKey}`) } await spinToTarget(prizeIndex) } catch (error) { onSpinError?.(error as Error) } finally { setIsSpinning(false) } }, [canSpin, isSpinning, onSpinStart, prizeIndexMap, onSpinError])

3. 模态框懒加载

按需加载模态框内容,减少初始渲染负担:

// 使用React.lazy懒加载模态框组件 const WinnerModal = React.lazy(() => import('./modals/WinnerModal')) const RulesModal = React.lazy(() => import('./modals/RulesModal')) const RewardsModal = React.lazy(() => import('./modals/RewardsModal')) // 在需要时才渲染 function WheelLottery() { return ( <div> {/* 轮盘主体 */} <WheelMain /> {/* 懒加载模态框 */} <Suspense fallback={null}> {modalState.winner.visible && ( <WinnerModal {...modalState.winner} /> )} {modalState.rules.visible && ( <RulesModal {...rulesModalConfig} /> )} {modalState.rewards.visible && ( <RewardsModal {...myRewardsModalConfig} /> )} </Suspense> </div> ) }

4. 图片优化

优化奖品图片加载性能:

// 使用Next.js Image组件(如果在Next.js环境) import Image from 'next/image' <Image src={prize.image.url} alt={prize.image.alt || prize.name} width={60} height={60} quality={75} loading="lazy" placeholder="blur" blurDataURL={generateBlurDataURL(prize.image.url)} /> // 或使用原生img lazy loading <img src={prize.image.url} alt={prize.image.alt || prize.name} loading="lazy" decoding="async" width="60" height="60" />

5. 事件节流

防止快速重复点击GO按钮:

/** * 节流处理 */ const throttledSpin = useMemo(() => { let isThrottled = false return async () => { if (isThrottled || isSpinning || !canSpin) return isThrottled = true try { await handleSpin() } finally { // 2秒后才允许再次点击 setTimeout(() => { isThrottled = false }, 2000) } } }, [isSpinning, canSpin, handleSpin])

6. 渲染优化

使用 React.memo 避免子组件不必要的重渲染:

// 奖品卡片组件 const PrizeCard = React.memo(({ prize }: { prize: Prize }) => { return ( <div className="prize-card"> <img src={prize.image.url} alt={prize.image.alt} /> <h3>{prize.name}</h3> <p>{prize.price}</p> {prize.rank && <RankBadge rank={prize.rank} />} </div> ) }, (prevProps, nextProps) => { // 自定义比较函数:只有prizeKey改变时才重渲染 return prevProps.prize.prizeKey === nextProps.prize.prizeKey }) // 机会获取卡片 const ChanceMethodCard = React.memo(({ method }: { method: ChanceMethod }) => { return ( <div className="chance-card"> <h3>{method.title}</h3> <p>{method.description}</p> <button onClick={method.onClick}> {method.buttonText} </button> </div> ) })

7. 虚拟滚动(可选)

如果中奖记录列表很长,使用虚拟滚动:

import { useVirtualizer } from '@tanstack/react-virtual' function MyRewardsList({ rewards }: { rewards: Prize[] }) { const parentRef = useRef<HTMLDivElement>(null) const virtualizer = useVirtualizer({ count: rewards.length, getScrollElement: () => parentRef.current, estimateSize: () => 80, // 每项高度80px overscan: 5, // 预渲染5项 }) return ( <div ref={parentRef} className="max-h-[400px] overflow-auto"> <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative', }} > {virtualizer.getVirtualItems().map((virtualRow) => { const reward = rewards[virtualRow.index] return ( <div key={virtualRow.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualRow.size}px`, transform: `translateY(${virtualRow.start}px)`, }} > <RewardItem reward={reward} /> </div> ) })} </div> </div> ) }

常见问题 FAQ

1. 必须配置8个奖品吗?

是的,WheelLottery 固定要求8个奖品。

轮盘UI设计为8个扇形(每个45度),改变奖品数量会导致布局错乱。如果实际奖品少于8个,可以用”再接再厉”或其他占位奖品填充。

// ❌ 错误:只配置6个奖品 const prizes = [ // ... 6个奖品 ] // ✅ 正确:用"try-again"填充至8个 const prizes = [ // ... 6个实物奖品 { prizeKey: 'try-again-1', name: 'Try Again', image: { url: '...', alt: 'Try Again' }, }, { prizeKey: 'try-again-2', name: 'Try Again', image: { url: '...', alt: 'Try Again' }, }, ]

2. 如何处理异步抽奖API超时?

使用 Promise.race 设置超时限制:

const handleSpinStart = async () => { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Lottery API timeout')), 30000) // 30秒超时 }) const apiPromise = fetch('/api/lottery/spin') .then((res) => res.json()) .then((data) => data.prizeKey) try { const prizeKey = await Promise.race([apiPromise, timeoutPromise]) return prizeKey } catch (error) { console.error('Spin failed:', error) throw error } }

3. 如何自定义轮盘背景图?

通过CSS变量或className自定义:

<WheelLottery prizes={prizes} userData={userData} onSpinStart={handleSpinStart} className="custom-wheel" />

CSS样式:

.custom-wheel { --wheel-bg-image: url('/images/custom-wheel-bg.png'); } .custom-wheel .wheel-container { background-image: var(--wheel-bg-image); background-size: cover; background-position: center; }

4. 如何禁用GO按钮?

通过 userData.availableChances 控制:

const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 0, // 0次机会,GO按钮自动禁用 totalChances: 5, }) // 或者手动控制 const [canSpin, setCanSpin] = useState(false) <WheelLottery prizes={prizes} userData={userData} onSpinStart={async () => { if (!canSpin) { throw new Error('Spin not allowed') } // ... 抽奖逻辑 }} />

5. 如何实现”每日限制3次”?

结合后端API和本地状态管理:

const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 0, totalChances: 3, }) // 页面加载时从后端获取今日剩余次数 useEffect(() => { fetch('/api/lottery/today-chances') .then((res) => res.json()) .then((data) => { setUserData((prev) => ({ ...prev, availableChances: data.remainingChances, })) }) }, []) // 每次抽奖后同步到后端 const handleSpinEnd = (prize: Prize) => { const newChances = userData.availableChances - 1 // 更新本地状态 setUserData((prev) => ({ ...prev, availableChances: newChances, })) // 同步到后端 fetch('/api/lottery/use-chance', { method: 'POST', }) }

6. 如何实现”保证中奖”逻辑?

在后端控制中奖概率,前端只负责展示:

// 后端伪代码 function handleLotterySpin(userId: string): string { const user = getUser(userId) // 检查是否满足保底条件(如连续10次未中奖) if (user.consecutiveNoWins >= 10) { // 保底中奖:返回实物奖品 return getGuaranteedPrize() } // 正常概率抽奖 const random = Math.random() if (random < 0.1) { // 10%概率中一等奖 return 'prize-001' } else if (random < 0.3) { // 20%概率中二等奖 return 'prize-002' } else if (random < 0.6) { // 30%概率中优惠券 return getRandomCoupon() } else { // 40%概率未中奖 user.consecutiveNoWins++ return 'try-again' } }

7. 如何追踪抽奖事件到GA4?

在回调函数中发送GA4事件:

const handleSpinEnd = (prize: Prize) => { // 更新状态 setUserData((prev) => ({ ...prev, availableChances: prev.availableChances - 1, })) // 发送GA4事件 if (typeof window !== 'undefined' && window.gtag) { window.gtag('event', 'lottery_win', { event_category: 'Engagement', event_label: prize.name, value: parseFloat(prize.price?.replace('$', '') || '0'), prize_key: prize.prizeKey, user_id: userData.email, }) } } const handleSpinError = (error: Error) => { // 发送错误事件 if (typeof window !== 'undefined' && window.gtag) { window.gtag('event', 'lottery_error', { event_category: 'Error', event_label: error.message, }) } }

8. 如何实现”分享后增加抽奖次数”?

结合分享模态框和后端验证:

const handleShare = async (platform: string) => { try { // 调用后端API验证分享并增加次数 const response = await fetch('/api/lottery/share', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ platform, userId: userData.email, }), }) const data = await response.json() if (data.success) { // 增加本地次数 setUserData((prev) => ({ ...prev, availableChances: prev.availableChances + 1, })) // 显示成功提示 alert('Share successful! +1 chance added') } } catch (error) { console.error('Share failed:', error) } } <WheelLottery prizes={prizes} userData={userData} onSpinStart={handleSpinStart} shareModalConfig={{ title: 'Share to Win', shareUrl: 'https://example.com/lottery', platforms: ['facebook', 'twitter', 'linkedin'], onShare: handleShare, }} />

9. 如何在服务端渲染(SSR)中使用?

确保组件在客户端渲染:

// 方法1: 使用'use client'指令(Next.js 13+) 'use client' import { WheelLottery } from '@anker-in/headless-ui/biz' export default function LotteryPage() { // ... 组件代码 } // 方法2: 动态导入(Next.js) import dynamic from 'next/dynamic' const WheelLottery = dynamic( () => import('@anker-in/headless-ui/biz').then((mod) => mod.WheelLottery), { ssr: false } ) export default function LotteryPage() { return <WheelLottery {...props} /> }

10. 如何处理多语言文案?

使用i18n库或传入翻译后的配置:

// 使用react-i18next import { useTranslation } from 'react-i18next' function MultiLanguageLottery() { const { t } = useTranslation() const [userData, setUserData] = useState({ isLoggedIn: true, availableChances: 3, totalChances: 5, }) const prizes = [ // ... 奖品配置(name使用t()翻译) ] return ( <WheelLottery prizes={prizes} userData={userData} onSpinStart={handleSpinStart} winnerModalConfig={{ title: t('lottery.winner.title'), confirmText: t('lottery.winner.confirm'), }} errorModalConfig={{ title: t('lottery.error.title'), message: t('lottery.error.message'), confirmText: t('lottery.error.confirm'), }} rulesModalConfig={{ rulesData: [ { title: t('lottery.rules.participate.title'), content: t('lottery.rules.participate.content'), }, { title: t('lottery.rules.winning.title'), content: [ t('lottery.rules.winning.item1'), t('lottery.rules.winning.item2'), t('lottery.rules.winning.item3'), ], }, ], rulesText: t('lottery.rules.button'), rulesTitle: t('lottery.rules.title'), }} /> ) }

翻译文件示例(en.json):

{ "lottery": { "winner": { "title": "Congratulations!", "confirm": "Claim Prize" }, "error": { "title": "Better Luck Next Time", "message": "Keep trying to win amazing prizes!", "confirm": "Try Again" }, "rules": { "button": "Rules", "title": "Lottery Rules", "participate": { "title": "How to Participate", "content": "Click GO button to spin the wheel" }, "winning": { "title": "Winning Rules", "item1": "Each user has 5 chances per day", "item2": "Prizes are distributed randomly", "item3": "Coupons expire in 30 days" } } } }

相关资源

  • Storybook 文档: /storybook/wheel-lottery - 查看所有变体和交互示例
  • 组件源码: /packages/ui/src/biz-components/WheelLottery - 查看完整实现代码
  • 类型定义: /packages/ui/src/biz-components/WheelLottery/types.ts - 完整TypeScript类型
  • Figma设计稿: WheelLottery Design 
  • 单元测试: /packages/ui/tests/WheelLottery.test.tsx - 测试用例和覆盖率
  • 命令式API文档: /docs/wheel-lottery-imperative-api.md - 详细的ref API说明
  • 动画性能优化指南: /docs/wheel-lottery-performance.md - 动画优化最佳实践
  • 分享模态框完整指南: /docs/wheel-lottery-share-modal.md - 社交分享集成教程
  • 后端集成示例: /examples/wheel-lottery-backend - Node.js/Python后端示例
  • 无障碍性测试报告: /docs/wheel-lottery-accessibility-audit.md - WCAG合规性报告
Last updated on