大转盘抽奖Wheel Lottery
组件分类: 活动营销组件 | 适用场景: 营销活动、节日促销、用户增长 | Figma: 查看设计稿
大转盘抽奖(Wheel Lottery) 是一个交互式抽奖组件,支持8奖品配置、异步抽奖API、多模态系统和机会获取方式。提供完整的抽奖流程管理和用户互动体验。
核心功能: 8奖品位配置、异步抽奖接口、中奖模态框、奖品展示模态框、规则说明模态框、机会获取方式、命令式API、响应式布局、动画效果
使用场景: 节日营销活动、新品推广抽奖、用户注册奖励、会员福利活动、电商促销活动
WheelLottery (大转盘抽奖)
大转盘抽奖互动组件,支持自定义奖品和抽奖动画【🔄 开发中】
功能特性
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 参数
主要参数
| 参数 | 类型 | 默认值 | 必需 | 说明 |
|---|---|---|---|---|
prizes | Prize[] | - | ✅ | 奖品列表,必须包含8个奖品 |
userData | UserData | - | ✅ | 用户数据(登录状态、可用次数、总次数、中奖记录等) |
chanceMethods | ChanceMethod[] | [] | ❌ | 获取抽奖机会的方式列表 |
theme | 'light' | 'dark' | 'light' | ❌ | 主题模式 |
spinDuration | number | 4000 | ❌ | 旋转动画持续时间(毫秒) |
onSpinStart | () => Promise<string> | - | ✅ | 开始抽奖回调,需返回中奖的 prizeKey |
onSpinEnd | (prize: Prize) => void | - | ❌ | 抽奖结束回调(不包括”try-again”) |
onSpinError | (error: Error) => void | - | ❌ | 抽奖错误回调 |
winnerModalConfig | WinnerModalConfig | - | ❌ | 中奖模态框配置 |
errorModalConfig | ErrorModalConfig | - | ❌ | 未中奖/错误模态框配置 |
rulesModalConfig | RulesModalConfig | - | ❌ | 规则模态框配置 |
myRewardsModalConfig | MyRewardsModalConfig | - | ❌ | 我的奖品模态框配置 |
shareModalConfig | ShareModalConfig | - | ❌ | 分享模态框配置 |
winningInfoConfig | WinningInfoConfig | - | ❌ | 中奖滚动信息配置 |
loginPromptConfig | LoginPromptConfig | - | ❌ | 登录提示配置 |
className | string | - | ❌ | 自定义样式类名 |
ref | React.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 transform 和 will-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. 状态管理优化
使用 useMemo 和 useCallback 避免不必要的重渲染:
// 缓存奖品索引查找
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合规性报告