Pool Hadiah · Vue 3 H5
ShortPro 瓜分奖池活动页(印尼语)— Vue 3 + Vite + TypeScript 实现。 100% 还原原型动画:宝箱开盖、金币爆开、彩带下落、奖池实时增长 +10 浮字、 任务进度槽位填充、Toast、中奖数字 count-up。
运行
npm install
npm run dev # 本地开发,http://localhost:5173
npm run build # 生产构建,输出到 dist/
npm run preview # 预览构建产物
Node 版本建议 18+。包管理可用 npm / pnpm / yarn,任选。
接入真实后端
所有接口和实时订阅集中在 src/api/pool.ts,找 TODO 注释替换即可。
| 接口 | 用途 | 调用位置 |
|---|---|---|
GET /pool/state |
初始奖池、参与人数、我的任务进度、倒计时 | fetchPoolState |
POST /pool/draw |
真实开奖,服务端返回 5-15 Koin | drawFromPool |
POST /referral/invite-link |
生成专属邀请链接 | getInviteLink |
POST /mission/claim |
3/3 完成后领 10 Koin | claimMissionReward |
WSS /pool/live |
订阅全网新用户 Lv.2 事件 | subscribePoolLive |
WSS /user/referral-events |
订阅当前用户的下级 Lv.2 事件 | subscribeMyReferrals |
环境变量(新建 .env):
VITE_API_BASE=https://api.shortpro.id
VITE_WS_BASE=wss://api.shortpro.id
状态机
开奖动画由 .stage 上的三个状态类驱动:
idle → 常态,宝箱呼吸,按钮光圈外扩
revealing → 宝箱剧烈抖动,光芒加速旋转 0.4s 脉冲,按钮变 spinner
revealed → 宝箱盖子翻开,28 枚金币径向飞散,50 片彩纸下落,
中奖数字从 0 缓动到 5-15,底部 actions 滑入
状态类由 useDraw composable 管理,页面 PoolHadiahPage.vue 通过 computed
把状态映射到 <div class="stage"> 的 class 上。
每日 21:00 开放规则
由 useDrawEligibility composable 管理,三个阶段:
| 阶段 | 触发条件 | 按钮样式 | 按钮文案 | 可点击 |
|---|---|---|---|---|
locked-before-21 |
当日 21:00 前(无论有多少次数) | 灰白 | DIBUKA JAM 21:00 · HH:MM:SS |
否 |
no-chances |
21:00 后 但 当前抽奖次数 = 0 | 灰白 | AJAK 3 TEMAN UNTUK DAPAT TARIKAN |
否 |
available |
21:00 后 且 抽奖次数 > 0 | 金色 | BUKA SEKARANG · Nx |
是 |
用户点到灰态按钮时,弹 toast 提示。
奖池快照与结算规则(重要)
每天 21:00 做奖池快照 → 当晚抽奖从这个冻结池子扣。
Day N:
00:00 ─────── 21:00 ─────── 24:00
└── 池子持续增长 ──┘
↑ 快照为 "Day N 晚池"
└── 抽奖窗口 · 从快照扣 · 同时 21:00+ 新拉新 → 累计到 Day N+1 的晚池
- Day N 晚池 在 21:00 冻结,金额 = 当日 00:00-21:00 间的累计值
- 21:00-24:00 的抽奖从 Day N 晚池扣,抽一次扣一次
- 抽奖金额由后端实时随机 5-15(不预分配),池子见底时自动封顶
- 21:00 后新产生的拉新贡献累计到 Day N+1 的晚池
- 服务端每日凌晨 00:00 重置日内计数器,开始新一轮累积
为什么这么设计: 1. 公平 — 晚上所有抽奖用户从同一池子里抽,早抽晚抽没区别 2. 可预算 — 后端能准确知道每晚要发多少 3. 透明 — 用户看得到"今晚奖池:X Koin"固定数字 4. 防刷 — 防止用户"邀-抽-邀-抽"循环操作
Grand Prize 终极幸运儿大奖
- 每次抽奖的 5% 自动进入 Grand Prize 累计桶(跨日累计)
- 页面奖池卡片下方持续展示 Grand Prize 金额
- 活动结束时,在所有曾经参与过(邀请或抽奖)的用户里随机抽一名
- 中奖用户获得全部 Grand Prize 奖金
UI 中的数据展示(重要)
| 元素 | 含义 | 更新节奏 | 来源 |
|---|---|---|---|
| 奖池主数字 | 今日奖池(21:00 前在涨,21:00 后从快照扣) | 实时 | subscribePoolLive |
| Koin dibagikan | 历史累计已发放金额(所有天) | 快速滚动 | 服务端 totalDistributed |
| Grand Prize | 累计到目前的 GP 金额 | 跟随抽奖事件 | 服务端 grandPrize |
任务 + 抽奖次数机制
由 usePoolState composable 管理,两阶段任务模型:
Phase 1(一次性,永久只能一次):
- 邀请 3 个 Lv.2 好友
- 完成后自动发放 10 Koin 到钱包
- phase1Claimed 置为 true,永不回退
- 任务自动进入 Phase 2
Phase 2(无限循环,可重复):
- 每邀请 3 个 Lv.2 好友 → drawChances += 1
- 任务进度 taskProgress 重置到 0,循环继续
- chancesEarnedTotal 累计历史获得次数(用于 UI "Total Nx" 展示)
抽奖机会: - 可累积,没有上限 - 21:00 前全部锁着(看得到数字,但不能抽) - 21:00 后每次开奖消耗 1 次,余 0 时按钮回到灰态 - 通过 localStorage 持久化(断网/刷新兜底,生产环境以服务端为准)
状态变更总表:
| 动作 | 变化 |
|---|---|
| 用户下级升 Lv.2 | taskProgress++, pool += 10 |
| Phase 1 进度到 3 | phase1Claimed = true, taskProgress = 0, 10 Koin 入钱包 |
| Phase 2 进度到 3 | drawChances++, chancesEarnedTotal++, taskProgress = 0 |
| 点击开奖成功 | drawChances--, pool -= prize, totalDistributed += prize, grandPrize += prize × 5% |
目录
src/
├── main.ts 入口
├── App.vue 根组件
├── env.d.ts Vue + Vite 类型
├── styles/
│ ├── reset.css 全局 reset
│ └── pool-hadiah.css 页面所有视觉与动画(非 scoped)
├── types/index.ts 领域类型
├── api/pool.ts 接口桩 + 实时订阅
├── composables/
│ ├── useCountdown.ts 通用倒计时 hh:mm:ss
│ ├── useLiveFeed.ts 底部跑马灯
│ ├── usePoolState.ts 奖池 + 参与人数 + 任务进度 + 浮字
│ ├── useDraw.ts 开奖流程编排 + 状态机
│ ├── useDrawEligibility.ts 21:00 开放规则 + 每日一次
│ └── useToast.ts 单例 Toast
└── components/
├── PoolHadiahPage.vue 主页面(组合所有状态,含粒子特效)
├── CoinSymbols.vue 全局 SVG defs(#coin, #coinFill)
├── TreasureChest.vue 宝箱 SVG(含 .lid 动画组)
└── Toast.vue Toast 展示层
重要工程细节
CSS 为什么不用 scoped
动画依赖 .stage.revealing .chest { ... } 这样的跨组件状态级联。
pool-hadiah.css 在 main.ts 中全局引入,类名即契约,子组件按命名对应渲染。
移动端适配
vite.config.ts 里配了 postcss-px-to-viewport-8-plugin,以 375px 为设计基准,
所有 px 自动转 vw,在 iPhone SE / iPhone 15 / 平板都按比例还原。
粒子效果为什么绕过 Vue 响应式
金币(28)和彩纸(50)是一次性动画,用 Vue 响应式做 list rendering 会拖帧。
spawnCoins / spawnConfetti 直接 document.createElement 追加到模板 ref 的
DOM 节点,动画结束后节点自然被下次 reset 清掉。
防连点与失败回滚
useDraw.ts 里有两层保护:
1. status.value !== 'idle' 时 draw() 直接 return
2. API 报错时 status.value = 'idle' 回滚,用户可以重试
低端机降级
PoolHadiahPage.vue 启动时检测 navigator.hardwareConcurrency,
核心数 ≤4 自动加 .low-end 类,隐藏 sparkle、grain、背光,关闭 backdrop-filter。
可以根据实际用户分布再细调阈值。
首屏字体
index.html 引 Google Fonts,生产环境建议换成自托管 + 字体子集化,
中文字符用不到的可以直接砍掉,仅保留 Bebas Neue、Fraunces、DM Sans 的拉丁子集,
首屏可以再省 200KB+。
埋点建议
前端接埋点 SDK 后,在以下时机上报:
pool_page_view 页面曝光(onMounted)
pool_draw_click 点"BUKA SEKARANG"
pool_draw_success 开奖成功(带 prize 字段)
pool_draw_fail 开奖失败(带 error 信息)
pool_invite_click 点"Undang"或"Undang Teman · Pool Naik"
pool_mission_complete 3/3 完成触发 claim
pool_share_copied 复制链接成功
已知可优化项
- [ ]
useCountdown当前不支持初始值变更后重同步,生产环境建议改为接受ref输入 - [ ] 金币粒子数量没按屏幕尺寸缩放,小屏可以降到 18 枚
- [ ] Pool 实时订阅没做断线重连,生产环境应加指数退避
- [ ] 没有 error boundary,建议在 App.vue 包一层
License
Proprietary · ShortPro