# Pool Hadiah · Vue 3 H5

ShortPro 瓜分奖池活动页(印尼语)— Vue 3 + Vite + TypeScript 实现。
100% 还原原型动画:宝箱开盖、金币爆开、彩带下落、奖池实时增长 +10 浮字、
任务进度槽位填充、Toast、中奖数字 count-up。

## 运行

```bash
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
