Compare commits

..

130 Commits
dev ... main

Author SHA1 Message Date
tsui110
1c62867cd2 又改了一个抖音版本 2026-01-10 20:19:14 +08:00
3b0bf07f77 优惠券请求的问题: 小程序没有请求 2026-01-09 00:48:26 +08:00
6da73a1955 任务中心的代码问题 2026-01-09 00:11:44 +08:00
tsui110
e05403b673 fix:修复活动页面可能被遮挡的问题 2026-01-08 23:41:39 +08:00
tsui110
c53e179ce2 feat:新增前端的假退出,真清除缓存功能,fix:修复后端大模型为对齐字段导致的前端积分显示不一致的问题。 2026-01-08 17:32:17 +08:00
01eb9a425a ci 2026-01-08 10:14:13 +08:00
tsui110
184305e6a0 feat:屏蔽商城,移除多余的抖音提示框。 2026-01-07 16:20:07 +08:00
tsui110
8cfe8a2a0c 提交了一个新的抖音审核版本 2026-01-07 14:50:33 +08:00
tsui110
77fb15426d fix 更改了说明文字 2026-01-07 09:53:56 +08:00
8963827c32 feat: 在创建订单前添加抽奖订阅消息请求 2026-01-07 09:49:09 +08:00
5c89355469 feat: 更新活动参与数据结构,移除 choices 字段,新增 channel、count 和 slot_index 字段 2026-01-07 09:35:48 +08:00
5cd4e77d07 Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini 2026-01-07 08:42:10 +08:00
470094dc75 feat: 为商城商品列表实现服务端筛选和分页功能,并调整积分显示逻辑。 2026-01-07 08:41:29 +08:00
tsui110
e903ae2d93 feat 接入抖店IM,修改抖音登录 2026-01-06 21:58:57 +08:00
tsui110
9d25477cd3 修复了抖音版本在微信中的问题 2026-01-06 19:55:33 +08:00
tsui110
0609f5c531 fix 针对抖音再提交一个版本 2026-01-06 17:26:55 +08:00
tsui110
a083681697 feat 上架抖音平台的各类调整 2026-01-06 13:02:51 +08:00
c1cf14b8fe chore: 更新 BASE_URL 为生产环境地址 2026-01-06 02:27:28 +08:00
7edb2e7844 feat: 添加公共配置获取与订阅消息模板加载 2026-01-06 02:24:42 +08:00
b9246bc728 fix: 修正积分显示逻辑,将 points_required 字段显示为整数并更新默认值。 2026-01-06 02:13:22 +08:00
c75946676a feat: 更新抖音绑定逻辑为直接绑定抖音ID,调整积分显示方式,并切换开发环境API地址 2026-01-06 02:02:38 +08:00
tsui110
ea7b3e33c0 fix:修改了显示的字符,feat:增加对战列表自动刷新的功能 2026-01-05 16:22:53 +08:00
tsui110
1d2599441e feat:观察者修改为画板模式 2026-01-05 14:04:10 +08:00
tsui110
96555e690c feat 小程序模式下禁止缩放棋盘 2026-01-05 11:59:39 +08:00
tsui110
5691d0601d fix feat 一大堆关羽扫雷的 2026-01-05 11:08:23 +08:00
237d785a4f feat: 添加抖音订单绑定功能并改进RPC日志。 2026-01-05 01:12:41 +08:00
tsui110
bcbb18a939 fix:合并后修复样式错误 2026-01-04 21:19:08 +08:00
420912b3a7 feat: 添加扫雷游戏在线人数显示及定时更新功能 2026-01-04 20:26:16 +08:00
41ab104f83 fix: 更新本地游戏状态以保持一致性 2026-01-04 19:19:52 +08:00
75b6ef7809 fix: 优化 WebSocket 连接和游戏重连逻辑,并改进回合计时器同步及用户操作反馈。 2026-01-04 16:29:57 +08:00
413f7557f1 feat: 为商品详情和列表页增加售罄状态显示与兑换限制,并更新 API BASE_URL。 2026-01-04 15:30:23 +08:00
29e3ecbdd4 Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini 2026-01-04 15:22:08 +08:00
3e0bc4423a feat: 优化任务奖励显示逻辑以优先使用后端名称并支持抽奖券类型,同时更新 API BASE_URL。 2026-01-04 15:21:42 +08:00
tsui110
874092a0d2 fix 扫雷用户区域头像占比缩小 2026-01-04 14:06:37 +08:00
tsui110
3aced9cae5 修改了扫雷强制结束 2026-01-04 13:52:43 +08:00
1b2315b4ea feat: 优化扫雷游戏重连认证流程,增强点击事件拦截日志,并规范 Nakama 消息发送的 op_code 类型。 2026-01-04 13:25:24 +08:00
d507122f2f feat: 增强 Nakama 认证支持外部用户 ID,并实现扫雷游戏对局恢复与发现功能 2026-01-04 12:40:24 +08:00
1d1c4f29d6 feat: 优化任务进度获取与聚合逻辑,并为扫雷游戏添加 gameToken 状态管理 2026-01-04 12:13:04 +08:00
0f7255783a refactor: 统一使用 getUserProfile 获取用户信息并为获取失败增加错误处理 2026-01-04 11:01:31 +08:00
762c248ab1 Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini 2026-01-03 22:58:30 +08:00
tsui110
e745d172ff fix:积分显示问题 2026-01-03 22:41:14 +08:00
241722e1af feat: 使 Nakama custom ID 持久化以确保用户身份一致性 2026-01-03 22:34:01 +08:00
tsui110
2c77f124c1 feat: improve the gamepasspurchasePopup page style 2026-01-03 22:21:24 +08:00
tsui110
676035c5d0 fix:修复扫雷的样式错误 2026-01-03 22:04:40 +08:00
83377543f8 feat: 优化游戏事件日志显示逻辑,优先使用 event.message 并完善 isMe 判断。 2026-01-03 19:16:21 +08:00
0367a8db8c fix: 优化 Nakama 心跳机制以防止僵尸心跳并修复扫雷游戏结算后的误触问题 2026-01-03 19:02:51 +08:00
tsui110
46430edb8b fix:修复道具放大镜 2026-01-03 18:37:14 +08:00
40cfb8c36e refactor: 调整扫雷游戏底部面板和弹窗样式,并移除部分引导和动画样式 2026-01-03 18:22:57 +08:00
45190e1004 feat: 为扫雷游戏添加房间列表功能,支持加入和围观现有对局。 2026-01-03 18:01:21 +08:00
tsui110
a304e66e75 fix:修复合并错误 2026-01-03 16:51:45 +08:00
tsui110
9309277047 Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini
# Conflicts:
#	pages-game/game/minesweeper/play.vue
2026-01-03 16:39:36 +08:00
tsui110
3d37bbc8d3 fix:修复了扫雷不弹出结算窗口 2026-01-03 16:38:41 +08:00
c028a29943 feat: 为扫雷游戏添加平局状态的 UI 显示和系统日志处理,并优化 gameState 访问检查 2026-01-03 16:25:28 +08:00
tsui110
3a1d4857dd fix:移除多余的手机号绑定判断逻辑 2026-01-03 16:01:28 +08:00
652528a14d Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini 2026-01-03 11:49:27 +08:00
f69fe30e2b feat: 添加 use_game_pass 参数到匹配预下单接口 2026-01-03 11:44:27 +08:00
tsui110
8d5cf5ee17 fix :修复缓存逻辑,避免无限增加
feat:新增前端限制修改昵称和头像需要7天
2026-01-03 09:26:03 +08:00
58d9edc766 feat: 添加游戏内动画特效,优化玩家卡片UI并调整布局。 2026-01-03 02:26:24 +08:00
tsui110
191895567c fix 扫雷格子和头像 2026-01-02 22:44:17 +08:00
41bf14eb8f Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini 2026-01-02 21:04:34 +08:00
b5241d767b feat: 调整了对手栏、玩家卡片和头像的尺寸及样式,并增加了底部面板和游戏日志的高度。 2026-01-02 21:03:36 +08:00
tsui110
bea2761453 fix 替换文本 2026-01-02 20:48:41 +08:00
tsui110
ce1522abf2 fix:替换各类文本 2026-01-02 20:40:22 +08:00
625dc1842a feat: nakamaManager 统一处理匹配数据 UTF-8 解码并调整 onmatchdata 数据格式,同时移除扫雷游戏页面的返回按钮。 2026-01-02 20:38:42 +08:00
tsui110
ac497ce163 feat:支持再来一次的按钮 2026-01-02 20:07:24 +08:00
tsui110
b959e634d2 fix:修复了很多不规范用词,更改了手机绑定校验逻辑,调整最大限制购买次数为200次。 2026-01-02 19:44:22 +08:00
5dfb2c3ecb feat: 在支付弹窗中实现多次卡与优惠券互斥,多次卡启用时禁用优惠券选择并清除已选优惠券。 2026-01-02 18:03:40 +08:00
tsui110
66f5c343d8 fix:修复对对碰次数卡显示文本不完整的问题 2026-01-02 17:38:35 +08:00
ed67c4f7fa feat: 增加支付前订单状态和实际支付金额判断,避免不必要的微信支付。 2026-01-02 17:31:11 +08:00
tsui110
5cbd30fcb7 fix:移除错误的逻辑判断 2026-01-02 17:18:29 +08:00
tsui110
152fe14aab fix:修复微信登录的错误 2026-01-02 16:40:48 +08:00
a8fa8bf557 feat: 购买次数卡弹窗新增数量选择功能并同步更新购买接口。 2026-01-02 16:32:12 +08:00
tsui110
4252a0ed61 Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini 2026-01-02 16:16:01 +08:00
7009b47de6 feat: 为活动支付和购买集成次数卡功能。 2026-01-02 16:15:00 +08:00
tsui110
05056c8188 feat:新增了绑定手机检查,抖音登录等逻辑,并且更改了页面样式以符合抖音要求 2026-01-02 16:03:33 +08:00
tsui110
61df7fca5e feat:新增头像和昵称修改 2026-01-02 12:48:16 +08:00
tsui110
9c3775624f Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini 2026-01-02 11:36:49 +08:00
tsui110
8237e3ef42 新增了抖音登录,区分不同场景的登录 2026-01-02 11:36:26 +08:00
a63fdd91d3 feat: 集成 Nakama 游戏后端并为扫雷游戏创建专用玩法页面,同时优化入口页 UI 和游戏资格购买流程 2026-01-02 11:12:56 +08:00
d4d298a275 feat: 增加游戏再来一局功能,并支持任务按订单金额统计进度。 2026-01-01 12:07:30 +08:00
tsui110
e24f05f6ac fix:修复了几个显示不完整的问题,移除了原有的缓存逻辑,避免无限增长缓存的问题。 2025-12-31 12:37:00 +08:00
tsui110
054b849374 修改了对对碰的背景样式 2025-12-30 23:39:24 +08:00
tsui110
ef4e4599f4 修改了对对碰的前端操作逻辑,需要手动摸牌 2025-12-30 19:29:29 +08:00
tsui110
a4dbfd14b7 兼容新版的对对碰,调整了更多的排序方式等内容 2025-12-30 15:28:04 +08:00
tsui110
952a2a2fe7 增加好友领取界面的可选地址列表 2025-12-30 10:16:03 +08:00
tsui110
21118ce6f9 修改了发货没有默认地址不跳转的问题,更改了商城页面加载逻辑,不再使用触底加载新页面,而是一次性加载所有商品 2025-12-30 10:03:44 +08:00
tsui110
a634c6caac feat:新增动画,修复一番赏的逻辑错误,无限赏和一番赏目前按照权重升序排列 2025-12-29 20:06:37 +08:00
tsui110
28e0721e3f feat:新增开屏动画,新增支付祝福动画,奖品目前按照权重升序,避免了S赏放最后的问题。 2025-12-29 01:38:03 +08:00
0bd10c6a0d feat: 优化活动奖励图片处理、登录流程及Authorization头设置,并改进对对碰活动奖励展示和排序逻辑 2025-12-28 22:48:28 +08:00
tsui110
d1fd76e242 feat:移除了不必要的缓存机制,确保数据的及时性 2025-12-28 13:34:51 +08:00
73cfd7ef9b feat: 添加短信登录功能并重构登录页面以支持微信和短信登录方式 2025-12-28 11:36:07 +08:00
3175c6e8ae refactor: 重构页面结构,将页面按模块拆分至pages-user、pages-activity等目录并更新相关配置和组件。 2025-12-28 00:23:55 +08:00
2af47b7979 feat: 新增开奖加载弹窗组件并统一奖品等级显示逻辑。 2025-12-27 22:50:51 +08:00
75638f895b feat: 新增开奖加载弹窗组件,统一活动等级显示逻辑,并优化柜子库存加载性能。 2025-12-27 21:21:30 +08:00
e19ec06d74 feat: 移除注册页,新增邀请落地页,优化分享流程、积分展示及活动加载,并添加分享图片。 2025-12-27 01:54:08 +08:00
3dde150cde feat: 新增地址提交与分享功能,优化活动记录列表显示用户及奖品信息,并支持抽奖页开发者模式 2025-12-26 17:28:57 +08:00
a3ec9c102d feat: 实现订单支付功能并优化支付成功后的页面跳转逻辑 2025-12-26 13:38:40 +08:00
b9b60b15a1 refactor: 移除获胜记录的百分比计算和显示。 2025-12-26 13:28:05 +08:00
4249ad3954 refactor: 将地址操作的点击事件替换为tap事件并添加调试日志。 2025-12-26 12:58:52 +08:00
6183fcaf15 feat: 新增 BoxReveal 和 LotteryResultPopup 组件,优化对对碰活动道具卡聚合逻辑,并调整商店道具卡页面为“暂未开放”提示。 2025-12-26 12:46:17 +08:00
7e08aa5f43 优化优惠券“去使用”按钮的样式和布局,并调整了底部行的对齐方式。 2025-12-26 02:21:46 +08:00
7406f8b308 feat: 新增我的优惠券、物品卡片、邀请、任务页面,并优化活动相关组件和页面。 2025-12-26 02:11:05 +08:00
d5527625bc feat: 支持扫雷游戏动态URL,增强活动页面滚动视图,并优化对对碰活动页签组件和奖励预览展示。 2025-12-26 00:01:43 +08:00
tsui110
f0e3cdc407 修复对对碰BUG 2025-12-25 23:49:10 +08:00
d1f005225a feat: 新增活动相关工具函数、缓存管理、Vue组合式函数及多个活动页面组件,并优化了YifanSelector的UI。 2025-12-25 20:35:42 +08:00
97cfe3f3da refactor: 重构活动页面,提取通用组件和组合式函数,并更新一番赏等页面以使用新组件 2025-12-25 20:35:12 +08:00
148c62a983 feat: 优化抽奖活动页面UI,新增奖池分级展示和购买记录功能。 2025-12-25 19:17:57 +08:00
tsui110
a18845c849 更改任务中心ui,移除首页的搜索入口,移除uniapp默认标题 2025-12-25 11:57:10 +08:00
tsui110
a2cffa84f0 支持取消发货 2025-12-25 11:37:52 +08:00
tsui110
449a91e582 移除了兑换积分的显示UI 2025-12-24 14:08:49 +08:00
bfb7d7630f feat: add Minesweeper game page and link from the index, including page registration. 2025-12-24 13:51:51 +08:00
tsui110
f57ecfbaee 修复了无限赏的按钮,背包的积分显示 2025-12-23 22:35:15 +08:00
tsui110
321189a3fe 修复了无限开奖动画的问题 2025-12-23 14:04:33 +08:00
tsui110
5b286d7e8a 道具卡显示使用时间精确到时分秒 2025-12-23 11:24:41 +08:00
tsui110
d49a3840a2 修改了道具卡显示为nan的问题 2025-12-23 11:00:10 +08:00
a350bcc4ed feat: 添加积分兑换商品功能及优化订单显示
- 在request.js中添加积分兑换商品API
- 在shop页面实现积分兑换功能及UI优化
- 在orders页面优化订单显示逻辑,支持优惠券和道具卡标签
- 在mine页面调整订单导航逻辑,支持跳转至cabinet指定tab
- 优化道具卡和优惠券的显示及状态处理
2025-12-22 21:06:54 +08:00
be57eda392 fix(orders): 修复订单列表显示问题并优化详情页展示
修复订单列表不显示 source_type=3 订单的问题,支持对对碰等玩法订单
优化订单标题显示逻辑,移除内部标识并添加保底显示
优化订单详情页,当没有实物商品时显示活动信息
重构订单类型判断逻辑,支持更多玩法类型
2025-12-22 14:40:53 +08:00
tsui110
2d218018e8 无限动画逻辑更新 2025-12-22 11:37:00 +08:00
0e174f220b feat: 添加扫雷游戏功能并更新相关页面
- 新增扫雷游戏页面和组件
- 更新首页游戏入口为扫雷挑战
- 添加测试登录按钮用于开发环境
- 修改请求基础URL为本地开发环境
- 在订单详情页添加抽奖凭证展示
2025-12-21 23:45:11 +08:00
tsui110
2571d4a698 无限赏更新UI 2025-12-21 14:38:42 +08:00
tsui110
9f7c98ddad 更新了对对碰预订单的api 2025-12-19 23:48:47 +08:00
tsui110
ad0232ad21 重修了对对碰部分逻辑 2025-12-19 21:49:49 +08:00
tsui110
4c3dfdd916 Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini 2025-12-19 09:39:20 +08:00
tsui110
f9bc754dec 更改一些文本,新增了用户邀请码复制 2025-12-19 09:26:02 +08:00
54ce24b7b8 feat(游戏): 添加对对碰游戏相关接口
添加开始游戏、执行配对、获取游戏状态和获取卡牌配置的接口
2025-12-19 09:17:43 +08:00
d930756130 feat(订单): 实现订单详情页功能
添加订单详情页路由配置
开发订单详情页UI及交互逻辑
对接订单详情和取消订单API
更新文档记录开发进度
优化订单状态显示逻辑
2025-12-18 15:06:32 +08:00
09ca0c252d Merge branch 'dev' 2025-12-18 14:40:13 +08:00
tsui110
ffd0073fdd 自动选择优惠券 2025-12-18 14:32:07 +08:00
tsui110
f3c0ab6d8f 修复一些基础样式错误,增加了前端一番赏定时类型显示下单的限制 2025-12-18 12:10:28 +08:00
tsui110
de1a80cc13 修改了一个小问题,增加了不同平台分享邀请的功能(未测试) 2025-12-17 23:06:44 +08:00
85 changed files with 31251 additions and 5246 deletions

3
.gitignore vendored
View File

@ -7,3 +7,6 @@ node_modules/
*.log
*.tmp
*.swp
.claude/settings.local.json
.hbuilderx/project.config.json
clean-cache.bat

42
App.vue
View File

@ -1,20 +1,34 @@
<script>
export default {
onLaunch: function(options) {
console.log('App Launch', options)
try { uni.setStorageSync('app_session_id', String(Date.now())) } catch (_) {}
if (options && options.query && options.query.invite_code) {
console.log('App Launch captured invite_code:', options.query.invite_code)
try { uni.setStorageSync('inviter_code', options.query.invite_code) } catch (e) { console.error('Save invite code failed', e) }
}
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
import { getPublicConfig } from '@/api/appUser'
export default {
onLaunch: function(options) {
console.log('App Launch', options)
try { uni.setStorageSync('app_session_id', String(Date.now())) } catch (_) {}
if (options && options.query && options.query.invite_code) {
console.log('App Launch captured invite_code:', options.query.invite_code)
try { uni.setStorageSync('inviter_code', options.query.invite_code) } catch (e) { console.error('Save invite code failed', e) }
}
// (ID)
getPublicConfig().then(res => {
if (res && res.subscribe_templates) {
console.log('Loaded public config:', res)
try { uni.setStorageSync('subscribe_templates', res.subscribe_templates) } catch (_) {}
}
}).catch(err => {
console.warn('Failed to load public config:', err)
})
//
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
}
}
</script>
<style lang="scss">

View File

@ -5,14 +5,68 @@ export function wechatLogin(code, invite_code) {
return request({ url: '/api/app/users/weixin/login', method: 'POST', data })
}
export function getInventory(user_id, page = 1, page_size = 20) {
return authRequest({ url: `/api/app/users/${user_id}/inventory`, method: 'GET', data: { page, page_size } })
// 抖音小程序登录
/**
* 抖音小程序登录
* @param {string} code - 抖音登录 code tt.login 获取
* @param {string} anonymous_code - 匿名登录 code可选
* @param {string} invite_code - 邀请码可选
*/
export function douyinLogin(code, anonymous_code, invite_code) {
const data = {}
if (code) data.code = code
if (anonymous_code) data.anonymous_code = anonymous_code
if (invite_code) data.invite_code = invite_code
return request({ url: '/api/app/users/douyin/login', method: 'POST', data })
}
// 保持向后兼容
export function toutiaoLogin(code, invite_code) {
return douyinLogin(code, null, invite_code)
}
// ============================================
// 短信登录 API
// ============================================
/**
* 发送短信验证码
* @param {string} mobile - 手机号
*/
export function sendSmsCode(mobile) {
return request({ url: '/api/app/sms/send-code', method: 'POST', data: { mobile } })
}
/**
* 短信验证码登录
* @param {string} mobile - 手机号
* @param {string} code - 验证码
* @param {string} invite_code - 可选邀请码
*/
export function smsLogin(mobile, code, invite_code) {
const data = { mobile, code }
if (invite_code) data.invite_code = invite_code
return request({ url: '/api/app/sms/login', method: 'POST', data })
}
export function getInventory(user_id, page = 1, page_size = 20, params = {}) {
return authRequest({ url: `/api/app/users/${user_id}/inventory`, method: 'GET', data: { page, page_size, ...params } })
}
export function bindPhone(user_id, code, extraHeader = {}) {
return authRequest({ url: `/api/app/users/${user_id}/phone/bind`, method: 'POST', data: { code }, header: extraHeader })
}
/**
* 绑定抖音手机号
* @param {number} user_id - 用户ID
* @param {string} code - 抖音手机号授权 code
*/
export function bindDouyinPhone(user_id, code) {
return authRequest({ url: `/api/app/users/${user_id}/douyin/phone/bind`, method: 'POST', data: { code } })
}
export function getUserStats(user_id) {
return authRequest({ url: `/api/app/users/${user_id}/stats`, method: 'GET' })
}
@ -74,12 +128,12 @@ export function getActivityIssueRewards(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/rewards`, method: 'GET' })
}
export function drawActivityIssue(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw`, method: 'POST' })
export function getIssueDrawLogs(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw_logs`, method: 'GET' })
}
export function getActivityWinRecords(activity_id, page = 1, page_size = 20) {
return authRequest({ url: `/api/app/activities/${activity_id}/wins`, method: 'GET', data: { page, page_size } })
export function drawActivityIssue(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw`, method: 'POST' })
}
export function getIssueChoices(activity_id, issue_id) {
@ -98,8 +152,20 @@ export function requestShipping(user_id, ids) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/request-shipping`, method: 'POST', data: { inventory_ids: ids } })
}
export function getItemCards(user_id, status) {
const data = {}
export function cancelShipping(user_id, batch_no) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/cancel-shipping`, method: 'POST', data: { batch_no } })
}
export function createAddressShare(user_id, inventory_id) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/address-share/create`, method: 'POST', data: { inventory_id } })
}
export function revokeAddressShare(user_id, inventory_id) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/address-share/revoke`, method: 'POST', data: { inventory_id } })
}
export function getItemCards(user_id, status, page = 1, page_size = 20) {
const data = { page, page_size }
if (status !== undefined) data.status = status
return authRequest({ url: `/api/app/users/${user_id}/item_cards`, method: 'GET', data })
}
@ -130,6 +196,24 @@ export function joinLottery(data) {
return authRequest({ url: '/api/app/lottery/join', method: 'POST', data })
}
/**
* 一番赏预下单接口
* @param {Object} data - 预下单数据
* @param {number} data.activity_id - 活动ID
* @param {number} data.issue_id - 期数ID
* @param {number[]} data.choices - 选择的位置数组
* @param {number} data.coupon_id - 优惠券ID可选
* @param {number} data.item_card_id - 道具卡ID可选
* @param {boolean} data.use_game_pass - 是否使用次数卡可选
*/
export function createIchibanPreorder(data) {
return authRequest({
url: '/api/app/ichiban/preorder',
method: 'POST',
data
})
}
export function createWechatOrder(data) {
return authRequest({ url: '/api/app/pay/wechat/jsapi/preorder', method: 'POST', data })
}
@ -146,10 +230,34 @@ export function redeemProductByPoints(user_id, product_id, quantity) {
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-product`, method: 'POST', data: { product_id, quantity } })
}
export function redeemItemCardByPoints(user_id, item_card_id, quantity = 1) {
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-item-card`, method: 'POST', data: { item_card_id, quantity } })
}
export function getStoreItems(kind = 'product', page = 1, page_size = 20, filters = {}) {
const data = { kind, page, page_size }
if (filters.keyword) data.keyword = filters.keyword
if (filters.price_min !== undefined && filters.price_min !== null && filters.price_min !== '') {
data.price_min = parseInt(filters.price_min)
}
if (filters.price_max !== undefined && filters.price_max !== null && filters.price_max !== '') {
data.price_max = parseInt(filters.price_max)
}
return authRequest({ url: '/api/app/store/items', method: 'GET', data })
}
export function getTasks(page = 1, page_size = 20) {
return authRequest({ url: '/api/app/task-center/tasks', method: 'GET', data: { page, page_size } })
}
export function getTaskProgress(task_id, user_id) {
return authRequest({ url: `/api/app/task-center/tasks/${task_id}/progress/${user_id}`, method: 'GET' })
}
export function claimTaskReward(task_id, user_id, tier_id) {
return authRequest({ url: `/api/app/task-center/tasks/${task_id}/claim/${user_id}`, method: 'POST', data: { tier_id } })
}
export function getShipments(user_id, page = 1, page_size = 20) {
return authRequest({ url: `/api/app/users/${user_id}/shipments`, method: 'GET', data: { page, page_size } })
}
@ -163,14 +271,142 @@ export function getUserInvites(user_id, page = 1, page_size = 20) {
// 兼容性适配接口 (适配 pages/mine/index.vue)
// ============================================
// ============================================
// 用户信息修改 API
// ============================================
/**
* 修改用户信息
* @param {number} user_id - 用户ID
* @param {object} data - 用户数据 { nickname, avatar(base64) }
*/
export function modifyUser(user_id, data) {
return authRequest({ url: `/api/app/users/${user_id}`, method: 'PUT', data })
}
/**
* 获取用户资料信息新接口
* @returns {Promise} 用户资料信息 { id, nickname, avatar, mobile, balance, invite_code, inviter_id }
*/
export function getUserProfile() {
return authRequest({ url: '/api/app/users/profile', method: 'GET' })
}
/**
* 获取用户信息兼容旧接口
* @deprecated 建议使用 getUserProfile
*/
export function getUserInfo() {
const user_info = uni.getStorageSync('user_info')
if (user_info) return Promise.resolve(user_info)
return authRequest({ url: '/api/app/users/info', method: 'GET' })
}
// 获取公开配置
export function getPublicConfig() {
return request({ url: '/api/app/config/public', method: 'GET' })
}
export const getUserTasks = getTasks
export function getInviteRecords(page = 1, page_size = 20) {
const user_id = uni.getStorageSync('user_id')
return getUserInvites(user_id, page, page_size)
}
// ============================================
// 对对碰游戏 (Matching Game) 接口
// ============================================
/**
* 开始游戏
* @param {number} issue_id - 对应的活动期次ID
*/
export function startMatchingGame(issue_id) {
return authRequest({ url: '/api/app/matching/start', method: 'POST', data: { issue_id } })
}
/**
* 执行配对 (下一轮)
* @param {string} game_id - start接口返回的游戏ID
*/
export function playMatchingGame(game_id) {
return authRequest({ url: '/api/app/matching/play', method: 'POST', data: { game_id } })
}
/**
* 获取所有启用的卡牌配置
*/
export function getMatchingCardTypes() {
return authRequest({ url: '/api/app/matching/card_types', method: 'GET' })
}
export function createMatchingPreorder({ issue_id, position, coupon_id = 0, item_card_id = 0, use_game_pass = false }) {
return authRequest({
url: '/api/app/matching/preorder',
method: 'POST',
data: { issue_id, position, coupon_id, item_card_id, use_game_pass }
})
}
export function checkMatchingGame(game_id, total_pairs) {
if (game_id && typeof game_id === 'object') {
total_pairs = game_id.total_pairs
game_id = game_id.game_id
}
return authRequest({
url: '/api/app/matching/check',
method: 'POST',
data: { game_id, total_pairs }
})
}
/**
* 支付成功后获取游戏数据
* @param {string} game_id - 游戏ID
*/
export function getMatchingGameCards(game_id) {
return authRequest({
url: '/api/app/matching/cards',
method: 'GET',
data: { game_id }
})
}
// ============================================
// 次数卡 (Game Pass) 接口
// ============================================
/**
* 获取用户可用的次数卡
* @param {number} activity_id - 活动ID不传返回所有
*/
export function getGamePasses(activity_id) {
const data = activity_id ? { activity_id } : {}
return authRequest({ url: '/api/app/game-passes/available', method: 'GET', data })
}
/**
* 获取可购买的次数卡套餐
* @param {number} activity_id - 活动ID不传返回全局套餐
*/
export function getGamePassPackages(activity_id) {
const data = activity_id ? { activity_id } : {}
return authRequest({ url: '/api/app/game-passes/packages', method: 'GET', data })
}
/**
* 购买次数卡套餐
* @param {number} package_id - 套餐ID
* @param {number} count - 购买数量
*/
export function purchaseGamePass(package_id, count = 1) {
return authRequest({ url: '/api/app/game-passes/purchase', method: 'POST', data: { package_id, count } })
}
/**
* 绑定抖音ID (Buyer ID)
* @param {string} douyin_id - 抖音号
*/
export function bindDouyinID(douyin_id) {
return authRequest({ url: '/api/app/users/douyin/bind', method: 'POST', data: { douyin_id } })
}

338
components/BoxReveal.vue Normal file
View File

@ -0,0 +1,338 @@
<template>
<view class="box-reveal-root">
<!-- Stage 1: The Box -->
<view v-if="stage === 'box'" class="box-stage" :class="{ shaking: isShaking }">
<view class="mystery-box">
<image class="box-img" src="/static/images/mystery-box.png" mode="widthFix" />
<view class="box-glow"></view>
</view>
<text class="box-tip">{{ isShaking ? '正在开启...' : '准备开启' }}</text>
</view>
<!-- Stage 2: Reveal Results -->
<view v-else-if="stage === 'result'" class="result-stage">
<view class="result-light-burst"></view>
<!-- Single Reward -->
<view v-if="rewards.length === 1" class="single-reward">
<view class="reward-card large bounce-in">
<image class="reward-img" :src="rewards[0].image" mode="aspectFit" />
<view class="reward-info">
<text class="reward-name">{{ rewards[0].title }}</text>
<text class="reward-desc">恭喜获得</text>
</view>
</view>
</view>
<!-- Multiple Rewards (Horizontal Scroll) -->
<scroll-view v-else scroll-x class="multi-rewards-scroll">
<view class="rewards-track">
<view
v-for="(item, index) in rewards"
:key="index"
class="reward-card small slide-in-right"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<view class="card-inner">
<image class="reward-img" :src="item.image" mode="aspectFit" />
<text class="reward-name">{{ item.title }}</text>
</view>
</view>
</view>
</scroll-view>
<view class="action-area">
<button class="confirm-btn" @tap="onConfirm">收下奖励</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
// No specific props needed for now, rewards passed via method
})
const emit = defineEmits(['close'])
const stage = ref('box') // box, result
const isShaking = ref(false)
const rewards = ref([])
// Public method to reset state
function reset() {
stage.value = 'box'
isShaking.value = false
rewards.value = []
}
// Public method to reveal results
function revealResults(list) {
const arr = Array.isArray(list) ? list : (list ? [list] : [])
rewards.value = arr
// Start animation sequence
isShaking.value = true
// Shake for 1.5s then open
setTimeout(() => {
isShaking.value = false
stage.value = 'result'
uni.vibrateLong()
}, 1500)
}
function onConfirm() {
emit('close')
}
defineExpose({ reset, revealResults })
</script>
<style lang="scss" scoped>
.box-reveal-root {
width: 100%;
min-height: 600rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
/* Stage 1: Box */
.box-stage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.mystery-box {
width: 400rpx;
height: 400rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.box-img {
width: 100%;
height: 100%;
position: relative;
z-index: 2;
// Fallback if image missing, use a block
min-height: 300rpx;
}
.box-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.6) 0%, transparent 70%);
z-index: 1;
filter: blur(40rpx);
animation: pulse 2s infinite;
}
.box-tip {
margin-top: 40rpx;
font-size: 32rpx;
color: $text-main;
font-weight: 600;
letter-spacing: 2rpx;
}
.shaking .mystery-box {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) infinite both;
}
@keyframes shake {
10%, 90% { transform: translate3d(-2px, 0, 0) rotate(-2deg); }
20%, 80% { transform: translate3d(4px, 0, 0) rotate(2deg); }
30%, 50%, 70% { transform: translate3d(-8px, 0, 0) rotate(-4deg); }
40%, 60% { transform: translate3d(8px, 0, 0) rotate(4deg); }
}
@keyframes pulse {
0% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.9); }
50% { opacity: 0.8; transform: translate(-50%, -50%) scale(1.1); }
100% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.9); }
}
/* Stage 2: Result */
.result-stage {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
animation: fade-in 0.5s ease-out;
}
.result-light-burst {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 800rpx;
height: 800rpx;
background: radial-gradient(circle, rgba(255, 200, 50, 0.2) 0%, transparent 70%);
animation: rotate-slow 10s linear infinite;
pointer-events: none;
z-index: 0;
}
/* Single Reward */
.single-reward {
position: relative;
z-index: 2;
margin-bottom: 60rpx;
}
.reward-card.large {
width: 460rpx;
background: #fff;
border-radius: 32rpx;
padding: 40rpx;
box-shadow: 0 20rpx 60rpx rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
align-items: center;
border: 4rpx solid $bg-secondary;
}
.reward-card.large .reward-img {
width: 320rpx;
height: 320rpx;
margin-bottom: 30rpx;
}
.reward-card.large .reward-name {
font-size: 36rpx;
font-weight: bold;
color: $text-main;
text-align: center;
margin-bottom: 12rpx;
line-height: 1.4;
}
.reward-card.large .reward-desc {
font-size: 24rpx;
color: $text-tertiary;
}
/* Multiple Rewards */
.multi-rewards-scroll {
width: 100%;
white-space: nowrap;
padding: 20rpx 0;
margin-bottom: 40rpx;
}
.rewards-track {
display: flex;
padding: 0 40rpx;
align-items: center;
}
.reward-card.small {
display: inline-block;
width: 240rpx;
height: 320rpx;
background: #fff;
border-radius: 20rpx;
margin-right: 24rpx;
box-shadow: $shadow-md;
overflow: hidden;
position: relative;
vertical-align: top;
}
.card-inner {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx;
box-sizing: border-box;
}
.reward-card.small .reward-img {
width: 160rpx;
height: 160rpx;
margin-bottom: 20rpx;
margin-top: 20rpx;
}
.reward-card.small .reward-name {
font-size: 24rpx;
color: $text-main;
font-weight: 600;
text-align: center;
white-space: normal;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
/* Animations */
.bounce-in {
animation: bounce-in 0.8s cubic-bezier(0.215, 0.61, 0.355, 1);
}
@keyframes bounce-in {
0% { opacity: 0; transform: scale(0.3); }
20% { transform: scale(1.1); }
40% { transform: scale(0.9); }
60% { opacity: 1; transform: scale(1.03); }
80% { transform: scale(0.97); }
100% { opacity: 1; transform: scale(1); }
}
.slide-in-right {
animation: slide-in-right 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
@keyframes slide-in-right {
0% { transform: translateX(100rpx); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes rotate-slow {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
.confirm-btn {
background: $gradient-brand;
color: #fff;
border-radius: 999rpx;
padding: 0 80rpx;
height: 88rpx;
line-height: 88rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: $shadow-warm;
border: none;
&:active {
transform: scale(0.96);
}
}
</style>

View File

@ -66,7 +66,7 @@ defineExpose({ revealResults, reset })
<style lang="scss" scoped>
/* ============================================
奇盒潮玩 - 翻牌动画组件
柯大鸭潮玩 - 翻牌动画组件
采用暖橙色调的开箱效果
============================================ */
@ -110,7 +110,8 @@ defineExpose({ revealResults, reset })
}
.flip-card {
perspective: 1000px;
perspective: 1200px;
transform: translateZ(0);
}
.flip-inner {
@ -118,11 +119,18 @@ defineExpose({ revealResults, reset })
width: 100%;
height: 220rpx;
transform-style: preserve-3d;
-webkit-transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
}
.flip-card.flipped .flip-inner {
transform: rotateY(180deg);
animation: flip-reveal 0.9s cubic-bezier(0.2, 0.9, 0.2, 1) both;
}
.flip-card.flipped {
animation: flip-pop 0.35s ease-out;
}
.flip-front, .flip-back {
@ -130,6 +138,7 @@ defineExpose({ revealResults, reset })
width: 100%;
height: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
border-radius: $radius-md;
overflow: hidden;
}
@ -155,6 +164,19 @@ defineExpose({ revealResults, reset })
50% { opacity: 1; transform: scale(1.05); }
}
@keyframes flip-pop {
0% { transform: translateZ(0) scale(1); }
60% { transform: translateZ(0) scale(1.06); }
100% { transform: translateZ(0) scale(1); }
}
@keyframes flip-reveal {
0% { transform: rotateY(0deg) rotateX(0deg) rotateZ(0deg) scale(1); }
35% { transform: rotateY(120deg) rotateX(14deg) rotateZ(-6deg) scale(1.08); }
70% { transform: rotateY(210deg) rotateX(-10deg) rotateZ(4deg) scale(1.02); }
100% { transform: rotateY(180deg) rotateX(0deg) rotateZ(0deg) scale(1); }
}
.flip-back {
background: $bg-card;
transform: rotateY(180deg);

View File

@ -0,0 +1,542 @@
<template>
<view>
<view v-if="visible" class="popup-mask" @tap="handleClose">
<view class="popup-content" @tap.stop>
<view class="popup-header">
<text class="title">使用次数<text class="title-sub">(次数需使用完剩余次数不可退)</text></text>
<view class="close-btn" @tap="handleClose">×</view>
</view>
<scroll-view scroll-y class="packages-list">
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<view v-else-if="!packages.length" class="empty-state">
<text>暂无优惠套餐</text>
</view>
<view
v-else
v-for="(pkg, index) in packages"
:key="pkg.id"
class="package-item"
:class="{ 'best-value': pkg.is_best_value }"
@tap="handlePurchase(pkg)"
>
<view class="pkg-tag" v-if="pkg.tag">{{ pkg.tag }}</view>
<view class="pkg-left">
<view class="pkg-name">{{ pkg.name }}</view>
<view class="pkg-count"> {{ pkg.pass_count }} 次游戏</view>
<view class="pkg-validity" v-if="pkg.valid_days > 0">有效期 {{ pkg.valid_days }} </view>
<view class="pkg-validity" v-else>永久有效</view>
</view>
<view class="pkg-right">
<view class="pkg-price-row">
<text class="currency">¥</text>
<text class="price">{{ (pkg.price / 100).toFixed(2) }}</text>
</view>
<view class="pkg-original-price" v-if="pkg.original_price > pkg.price">
¥{{ (pkg.original_price / 100).toFixed(2) }}
</view>
<view class="action-row">
<view class="stepper" @tap.stop>
<text class="step-btn minus" @tap="updateCount(pkg.id, -1)">-</text>
<input
class="step-input"
type="number"
:value="counts[pkg.id] || 1"
@input="onInputCount(pkg.id, $event)"
@blur="onBlurCount(pkg.id)"
/>
<text class="step-btn plus" @tap="updateCount(pkg.id, 1)">+</text>
</view>
<button class="btn-buy" :loading="purchasingId === pkg.id" @tap.stop="handlePurchase(pkg)">
购买
</button>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
import { getGamePassPackages, purchaseGamePass, createWechatOrder } from '@/api/appUser'
const props = defineProps({
visible: { type: Boolean, default: false },
activityId: { type: [String, Number], default: '' }
})
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const packages = ref([])
const purchasingId = ref(null)
const counts = ref({})
function updateCount(pkgId, delta) {
const current = counts.value[pkgId] || 1
const newVal = current + delta
if (newVal >= 1 && newVal <= 200) {
counts.value[pkgId] = newVal
}
}
function onInputCount(pkgId, e) {
const val = parseInt(e.detail.value) || 1
//
if (val >= 1 && val <= 200) {
counts.value[pkgId] = val
} else if (val < 1) {
counts.value[pkgId] = 1
} else if (val > 200) {
counts.value[pkgId] = 200
}
}
function onBlurCount(pkgId) {
//
const current = counts.value[pkgId] || 1
if (current < 1) {
counts.value[pkgId] = 1
} else if (current > 200) {
counts.value[pkgId] = 200
}
}
watch(() => props.visible, (val) => {
if (val) {
fetchPackages()
}
})
async function fetchPackages() {
loading.value = true
try {
const res = await getGamePassPackages(props.activityId)
// res
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.packages)) list = res.packages
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
// counts
const countMap = {}
list.forEach(p => countMap[p.id] = 1)
counts.value = countMap
//
// ""
packages.value = list.map(p => {
let tag = ''
const discount = 1 - (p.price / p.original_price)
if (p.original_price > 0 && discount >= 0.2) {
tag = `${Math.floor(discount * 100)}%`
}
return { ...p, tag }
})
} catch (e) {
console.error(e)
packages.value = []
} finally {
loading.value = false
}
}
async function handlePurchase(pkg) {
if (purchasingId.value) return
purchasingId.value = pkg.id
try {
uni.showLoading({ title: '创建订单...' })
// 1. ( + )
// purchaseGamePass createWechatOrder
// game_passes_app.go simple success?
// game_passes_app.go PurchaseGamePassPackage
//
// order_no createWechatOrder
// pay_params
// API { order_no, ... }
const count = counts.value[pkg.id] || 1
const res = await purchaseGamePass(pkg.id, count)
const orderNo = res.order_no || res.orderNo
if (!orderNo) throw new Error('下单失败')
// 2.
const openid = uni.getStorageSync('openid')
const payRes = await createWechatOrder({ openid, order_no: orderNo })
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'RSA',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
uni.showToast({ title: '购买成功', icon: 'success' })
emit('success')
handleClose()
} catch (e) {
if (e?.errMsg && e.errMsg.includes('cancel')) {
uni.showToast({ title: '取消支付', icon: 'none' })
} else {
uni.showToast({ title: e.message || '购买失败', icon: 'none' })
}
} finally {
uni.hideLoading()
purchasingId.value = null
}
}
function handleClose() {
emit('update:visible', false)
}
</script>
<style lang="scss" scoped>
.popup-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(10rpx);
z-index: 999;
display: flex;
align-items: flex-end;
animation: fadeIn 0.25s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.popup-content {
width: 100%;
background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFF 100%);
border-radius: 40rpx 40rpx 0 0;
box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.12);
padding-bottom: env(safe-area-inset-bottom);
max-height: 82vh;
display: flex;
flex-direction: column;
animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.popup-header {
padding: 40rpx 32rpx 24rpx;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2rpx solid #F0F2F5;
background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFF 100%);
position: relative;
&::after {
content: '';
position: absolute;
bottom: -8rpx;
left: 50%;
transform: translateX(-50%);
width: 80rpx;
height: 6rpx;
background: #E5E7EB;
border-radius: 3rpx;
}
.title {
font-size: 38rpx;
font-weight: 800;
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.5rpx;
}
.title-sub {
font-size: 22rpx;
font-weight: 400;
color: #9CA3AF;
margin-left: 8rpx;
-webkit-text-fill-color: #9CA3AF;
}
.close-btn {
font-size: 52rpx;
color: #CBD5E1;
line-height: 0.8;
padding: 8rpx;
font-weight: 200;
transition: all 0.2s ease;
&:active {
color: #667EEA;
transform: rotate(90deg);
}
}
}
.packages-list {
padding: 32rpx 24rpx;
max-height: 62vh;
}
.loading-state, .empty-state {
text-align: center;
padding: 80rpx 0;
color: #9CA3AF;
font-size: 28rpx;
font-weight: 500;
&::before {
content: '📦';
display: block;
font-size: 88rpx;
margin-bottom: 16rpx;
opacity: 0.4;
}
}
.package-item {
position: relative;
background: linear-gradient(145deg, #FFFFFF 0%, #F8F9FF 100%);
border: 2rpx solid #E8EEFF;
border-radius: 28rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.08);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 6rpx;
background: linear-gradient(90deg, #667EEA 0%, #764BA2 100%);
opacity: 0;
transition: opacity 0.3s;
}
&:active {
transform: scale(0.97);
box-shadow: 0 2rpx 12rpx rgba(102, 126, 234, 0.12);
}
&:active::before {
opacity: 1;
}
}
.pkg-tag {
position: absolute;
top: 0;
left: 0;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: #FFF;
font-size: 20rpx;
padding: 6rpx 16rpx;
border-bottom-right-radius: 16rpx;
font-weight: 700;
letter-spacing: 0.5rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 107, 0.3);
z-index: 1;
}
.pkg-left {
flex: 1;
padding-right: 16rpx;
}
.pkg-name {
font-size: 34rpx;
font-weight: 700;
color: #1F2937;
margin-bottom: 10rpx;
letter-spacing: 0.3rpx;
}
.pkg-count {
font-size: 26rpx;
color: #6B7280;
margin-bottom: 6rpx;
font-weight: 500;
display: flex;
align-items: center;
&::before {
content: '🎮';
font-size: 22rpx;
margin-right: 6rpx;
}
}
.pkg-validity {
font-size: 22rpx;
color: #9CA3AF;
font-weight: 400;
display: flex;
align-items: center;
&::before {
content: '⏰';
font-size: 18rpx;
margin-right: 4rpx;
}
}
.pkg-right {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 200rpx;
}
.pkg-price-row {
color: #FF6B6B;
font-weight: 800;
margin-bottom: 6rpx;
display: flex;
align-items: baseline;
letter-spacing: -0.5rpx;
.currency {
font-size: 26rpx;
font-weight: 700;
margin-right: 2rpx;
}
.price {
font-size: 44rpx;
text-shadow: 0 2rpx 8rpx rgba(255, 107, 107, 0.15);
}
}
.pkg-original-price {
font-size: 22rpx;
color: #CBD5E1;
text-decoration: line-through;
margin-bottom: 10rpx;
font-weight: 500;
}
.btn-buy {
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
color: #FFF;
font-size: 26rpx;
padding: 0 28rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
border: none;
font-weight: 700;
box-shadow: 0 6rpx 20rpx rgba(102, 126, 234, 0.35);
transition: all 0.3s ease;
letter-spacing: 0.5rpx;
&[loading] {
opacity: 0.8;
transform: scale(0.95);
}
&:active {
transform: scale(0.92);
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.4);
}
&::after {
border: none;
}
}
.action-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 8rpx;
}
.stepper {
display: flex;
align-items: center;
background: linear-gradient(145deg, #F1F3F9 0%, #E8EEFF 100%);
border: 2rpx solid #E0E7FF;
border-radius: 16rpx;
padding: 4rpx;
box-shadow: inset 0 2rpx 6rpx rgba(102, 126, 234, 0.06);
.step-btn {
width: 48rpx;
height: 48rpx;
line-height: 44rpx;
text-align: center;
font-size: 32rpx;
color: #667EEA;
font-weight: 600;
flex-shrink: 0;
transition: all 0.2s ease;
border-radius: 12rpx;
&:active {
background: rgba(102, 126, 234, 0.1);
transform: scale(0.9);
}
}
.minus {
color: #9CA3AF;
&:active {
color: #667EEA;
background: rgba(102, 126, 234, 0.1);
}
}
.step-input {
width: 64rpx;
height: 48rpx;
line-height: 48rpx;
text-align: center;
font-size: 28rpx;
font-weight: 700;
color: #1F2937;
background: transparent;
border: none;
padding: 0;
margin: 0;
&::placeholder {
color: #CBD5E1;
}
}
}
</style>

248
components/MatchingGame.vue Normal file
View File

@ -0,0 +1,248 @@
<template>
<view class="matching-game-overlay" v-if="visible" @touchmove.stop.prevent>
<view class="game-mask"></view>
<view class="game-container">
<view class="game-header">
<text class="game-title">翻牌配对</text>
<view class="game-stats">
<text>已配对: {{ pairsFound }}</text>
<text>剩余: {{ cards.length / 2 - pairsFound }}</text>
</view>
</view>
<view class="game-grid" :style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }">
<view
v-for="(card, index) in gameCards"
:key="index"
class="game-card"
:class="{ flipped: card.flipped || card.matched, matched: card.matched }"
@tap="onCardTap(index)"
>
<view class="card-inner">
<view class="card-front">
<view class="pattern"></view>
</view>
<view class="card-back">
<image :src="card.image" mode="aspectFit" class="card-img" />
</view>
</view>
</view>
</view>
<button class="submit-btn" @tap="forceSubmit" v-if="gameOver"> </button>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
cards: { type: Array, default: () => [] }, // Array of { id, image, type... }
gameId: { type: String, default: '' }
})
const emit = defineEmits(['finish', 'close'])
const gameCards = ref([])
const pairsFound = ref(0)
const selectedIndices = ref([])
const isProcessing = ref(false)
const gameOver = ref(false)
const gridCols = computed(() => {
const len = props.cards.length
if (len <= 9) return 3
if (len <= 16) return 4
return 4
})
watch(() => props.visible, (val) => {
if (val) initGame()
})
function initGame() {
// Initialize game cards with state
// Assuming props.cards is already the shuffled list of cards for the board
let list = JSON.parse(JSON.stringify(props.cards))
// If we only got types, we might need to duplicate them?
// User says "initializes the game session with shuffled cards".
// I assume the server sends the exact layout.
gameCards.value = list.map(c => ({
...c,
flipped: false,
matched: false
}))
pairsFound.value = 0
selectedIndices.value = []
isProcessing.value = false
gameOver.value = false
// Flash all cards briefly?
setTimeout(() => {
gameCards.value.forEach(c => c.flipped = true)
setTimeout(() => {
gameCards.value.forEach(c => c.flipped = false)
}, 2000)
}, 500)
}
function onCardTap(index) {
if (isProcessing.value) return
const card = gameCards.value[index]
if (card.flipped || card.matched) return
// Flip card
card.flipped = true
selectedIndices.value.push(index)
if (selectedIndices.value.length === 2) {
checkMatch()
}
}
function checkMatch() {
isProcessing.value = true
const [idx1, idx2] = selectedIndices.value
const card1 = gameCards.value[idx1]
const card2 = gameCards.value[idx2]
// Assuming 'title' or 'id' connects them.
// Better use an explicit 'type' or compare 'title/image'.
// Using image as the matcher for now if no type.
const isMatch = (card1.type && card1.type === card2.type) || (card1.image === card2.image)
if (isMatch) {
setTimeout(() => {
card1.matched = true
card2.matched = true
pairsFound.value++
selectedIndices.value = []
isProcessing.value = false
checkGameOver()
}, 500)
} else {
setTimeout(() => {
card1.flipped = false
card2.flipped = false
selectedIndices.value = []
isProcessing.value = false
}, 1000)
}
}
function checkGameOver() {
// Check if all pairs found
// Note: If odd number of cards (9), 1 will remain.
const totalPairsPossible = Math.floor(props.cards.length / 2)
if (pairsFound.value >= totalPairsPossible) {
gameOver.value = true
}
}
function forceSubmit() {
emit('finish', {
gameId: props.gameId,
totalPairs: pairsFound.value
})
}
</script>
<style lang="scss" scoped>
.matching-game-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
z-index: 10000;
display: flex; align-items: center; justify-content: center;
}
.game-mask {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85);
backdrop-filter: blur(10px);
}
.game-container {
position: relative; z-index: 10;
width: 680rpx;
background: #fff;
border-radius: 32rpx;
padding: 40rpx;
box-shadow: 0 20rpx 60rpx rgba(0,0,0,0.3);
animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.game-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 40rpx;
}
.game-title {
font-size: 40rpx; font-weight: 800; color: #333;
}
.game-stats {
font-size: 28rpx; color: #666; font-weight: 600;
}
.game-grid {
display: grid; gap: 20rpx;
margin-bottom: 40rpx;
}
.game-card {
aspect-ratio: 1;
perspective: 1000rpx;
}
.card-inner {
position: relative; width: 100%; height: 100%;
transform-style: preserve-3d;
transition: transform 0.5s;
}
.game-card.flipped .card-inner {
transform: rotateY(180deg);
}
.game-card.matched .card-inner {
transform: rotateY(180deg);
}
.game-card.matched {
animation: pulse 1s infinite;
}
.card-front, .card-back {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
backface-visibility: hidden;
border-radius: 16rpx;
box-shadow: 0 4rpx 10rpx rgba(0,0,0,0.1);
}
.card-front {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%);
display: flex; align-items: center; justify-content: center;
}
.pattern {
width: 60%; height: 60%;
background: rgba(255,255,255,0.3);
border-radius: 50%;
}
.card-back {
background: #fff;
transform: rotateY(180deg);
display: flex; align-items: center; justify-content: center;
padding: 10rpx;
}
.card-img {
width: 80%; height: 80%;
}
.submit-btn {
background: linear-gradient(90deg, #ff758c 0%, #ff7eb3 100%);
color: #fff;
font-weight: 800;
border-radius: 50rpx;
margin-top: 20rpx;
box-shadow: 0 10rpx 20rpx rgba(255, 117, 140, 0.4);
}
@keyframes popIn {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
</style>

View File

@ -1,65 +1,120 @@
<template>
<view v-if="visible" class="payment-popup-mask" @tap="handleMaskClick">
<view class="payment-popup-content" @tap.stop>
<!-- 顶部提示 -->
<view class="risk-warning">
<text>盲盒具有随机性请理性消费购买即表示同意</text>
<text class="agreement-link" @tap="openAgreement">购买协议</text>
<view>
<!-- 祝福动画 -->
<view v-if="showBlessing" class="blessing-container">
<view class="blessing-animation" :class="currentBlessing.type">
<view class="blessing-emoji">{{ currentBlessing.emoji }}</view>
<view v-if="currentBlessing.type === 'sheep'" class="blessing-subtitle">小羊祝你</view>
<view class="blessing-text">
<text v-for="(char, index) in currentBlessing.chars"
:key="index"
class="char"
:class="{ 'from-left': index % 2 === 0, 'from-right': index % 2 === 1 }"
:style="{ animationDelay: index * 0.15 + 's' }">
{{ char }}
</text>
</view>
</view>
</view>
<view class="popup-header">
<text class="popup-title">确认支付</text>
<view class="close-icon" @tap="handleClose">×</view>
</view>
<view class="popup-body">
<view class="amount-section" v-if="amount !== undefined && amount !== null">
<text class="label">支付金额</text>
<text class="amount">¥{{ amount }}</text>
<!-- 支付弹窗 -->
<view v-if="visible" class="payment-popup-mask" @tap="handleMaskClick">
<view class="payment-popup-content" @tap.stop>
<!-- 顶部提示 -->
<view class="risk-warning">
<text>盲盒具有随机性请理性消费购买即表示同意</text>
<text class="agreement-link" @tap="openAgreement">购买协议</text>
</view>
<view class="form-item">
<text class="label">优惠券</text>
<picker
mode="selector"
:range="coupons"
range-key="name"
@change="onCouponChange"
:value="couponIndex"
:disabled="!coupons || coupons.length === 0"
>
<view class="picker-display">
<text v-if="selectedCoupon" class="selected-text">{{ selectedCoupon.name }} (-¥{{ selectedCoupon.amount }})</text>
<text v-else-if="!coupons || coupons.length === 0" class="placeholder">暂无优惠券可用</text>
<text v-else class="placeholder">请选择优惠券</text>
<text class="arrow"></text>
<view class="popup-header">
<text class="popup-title">确认支付</text>
<view class="close-icon" @tap="handleClose">×</view>
</view>
<view class="popup-body">
<!-- 次数卡选项有数据时显示 -->
<view v-if="gamePasses" class="game-pass-section">
<view
class="game-pass-option"
:class="{ active: useGamePass, disabled: gamePassRemaining <= 0 }"
@tap="gamePassRemaining > 0 ? toggleGamePass() : null"
>
<view class="game-pass-radio">
<view v-if="useGamePass" class="radio-checked"></view>
<view v-else-if="gamePassRemaining <= 0" class="radio-disabled" />
</view>
<view class="game-pass-info">
<text class="game-pass-label" :class="{ 'text-disabled': gamePassRemaining <= 0 }">剩余次数</text>
<text class="game-pass-count" v-if="gamePassRemaining > 0">{{ gamePassRemaining }} </text>
<text class="game-pass-count text-disabled" v-else>暂无可用次数卡</text>
</view>
</view>
</picker>
</view>
<view class="form-item" v-if="showCards">
<text class="label">道具卡</text>
<picker
mode="selector"
:range="propCards"
range-key="name"
@change="onCardChange"
:value="cardIndex"
:disabled="!propCards || propCards.length === 0"
>
<view class="picker-display">
<text v-if="selectedCard" class="selected-text">{{ selectedCard.name }}</text>
<text v-else-if="!propCards || propCards.length === 0" class="placeholder">暂无道具卡可用</text>
<text v-else class="placeholder">请选择道具卡</text>
<text class="arrow"></text>
<view v-if="!useGamePass" class="divider-line">
<text class="divider-text">或选择其他支付方式</text>
</view>
</picker>
</view>
</view>
</view>
<view class="popup-footer">
<button class="btn-cancel" @tap="handleClose">取消</button>
<button class="btn-confirm" @tap="handleConfirm">确认支付</button>
<view class="amount-section" v-if="!useGamePass && amount !== undefined && amount !== null">
<text class="label">支付金额</text>
<text class="amount">¥{{ finalPayAmount }}</text>
<text v-if="finalPayAmount < amount" class="original-amount" style="text-decoration: line-through; color: #999; font-size: 24rpx; margin-left: 10rpx;">¥{{ amount }}</text>
</view>
<view class="form-item">
<text class="label">优惠券</text>
<picker
class="picker-full"
mode="selector"
:range="coupons"
range-key="name"
@change="onCouponChange"
:value="couponIndex"
:disabled="(!coupons || coupons.length === 0) || useGamePass"
>
<view class="picker-display" :class="{ 'picker-disabled': useGamePass }">
<text v-if="useGamePass" class="placeholder" style="color: #666;">
多次卡不可与优惠券同享
</text>
<text v-if="selectedCoupon" class="selected-text">
{{ selectedCoupon.name }} (-¥{{ effectiveCouponDiscount.toFixed(2) }})
<text v-if="selectedCoupon.amount > maxDeductible" style="font-size: 20rpx; color: #FF9800;">(最高抵扣50%)</text>
</text>
<text v-else-if="!coupons || coupons.length === 0" class="placeholder">暂无优惠券可用</text>
<text v-else class="placeholder">请选择优惠券</text>
<text class="arrow"></text>
</view>
</picker>
</view>
<view class="form-item" v-if="showCards">
<text class="label">道具卡</text>
<picker
class="picker-full"
mode="selector"
:range="displayCards"
range-key="displayName"
@change="onCardChange"
:value="cardIndex"
:disabled="!displayCards || displayCards.length === 0"
>
<view class="picker-display">
<text v-if="selectedCard" class="selected-text">
{{ selectedCard.name }}
<text v-if="Number(selectedCard.count) > 1" style="color: #999; font-size: 24rpx; margin-left: 6rpx;">(拥有: {{ selectedCard.count }})</text>
</text>
<text v-else-if="!displayCards || displayCards.length === 0" class="placeholder">暂无道具卡可用</text>
<text v-else class="placeholder">请选择道具卡</text>
<text class="arrow"></text>
</view>
</picker>
</view>
</view>
<view class="popup-footer">
<button class="btn-cancel" @tap="handleClose">取消</button>
<button v-if="useGamePass" class="btn-confirm btn-game-pass" @tap="handleConfirm">🎮 使用次数卡</button>
<button v-else class="btn-confirm" @tap="handleConfirm">确认支付</button>
</view>
</view>
</view>
</view>
@ -73,13 +128,101 @@ const props = defineProps({
amount: { type: [Number, String], default: 0 },
coupons: { type: Array, default: () => [] },
propCards: { type: Array, default: () => [] },
showCards: { type: Boolean, default: true }
showCards: { type: Boolean, default: true },
gamePasses: { type: Object, default: () => null } // { total_remaining, passes }
})
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
//
const showBlessing = ref(false)
const blessings = [
{
emoji: '🐏',
chars: ['三', '羊', '开', '泰'],
type: 'sheep'
},
{
emoji: '🐴',
chars: ['一', '马', '当', '先'],
type: 'horse'
},
{
emoji: '🍊',
chars: ['心', '想', '事', '橙'],
type: 'orange'
},
{
emoji: '🐵',
chars: ['财', '源', '广', '进'],
type: 'monkey'
},
{
emoji: '🐮',
chars: ['牛', '气', '冲', '天'],
type: 'ox'
},
{
emoji: '🐶',
chars: ['旺', '旺', '旺', '旺'],
type: 'dog'
},
{
emoji: '🐔',
chars: ['吉', '祥', '如', '意'],
type: 'chicken'
}
]
const currentBlessing = ref(blessings[0])
//
watch(() => props.visible, (newVal) => {
console.log('[PaymentPopup] visible changed:', newVal)
if (newVal) {
//
const index = Math.floor(Math.random() * blessings.length)
currentBlessing.value = blessings[index]
//
setTimeout(() => {
console.log('[PaymentPopup] 显示祝福动画')
showBlessing.value = true
// 3
setTimeout(() => {
showBlessing.value = false
console.log('[PaymentPopup] 隐藏祝福动画')
}, 3000)
}, 300) // 300ms
} else {
showBlessing.value = false
}
})
const couponIndex = ref(-1)
const cardIndex = ref(-1)
const useGamePass = ref(false)
//
const gamePassRemaining = computed(() => {
return props.gamePasses?.total_remaining || 0
})
//
watch(() => props.visible, (newVal) => {
if (newVal) {
//
useGamePass.value = gamePassRemaining.value > 0
}
})
function toggleGamePass() {
useGamePass.value = !useGamePass.value
// Mutually Exclusive: If Game Pass is ON, clear Coupon.
if (useGamePass.value) {
couponIndex.value = -1
}
}
const selectedCoupon = computed(() => {
if (couponIndex.value >= 0 && props.coupons[couponIndex.value]) {
@ -88,20 +231,53 @@ const selectedCoupon = computed(() => {
return null
})
const maxDeductible = computed(() => {
const amt = Number(props.amount) || 0
return amt * 0.5
})
const effectiveCouponDiscount = computed(() => {
if (!selectedCoupon.value) return 0
const couponAmt = Number(selectedCoupon.value.amount) || 0
return Math.min(couponAmt, maxDeductible.value)
})
const displayCards = computed(() => {
if (!Array.isArray(props.propCards)) return []
return props.propCards.map(c => ({
...c,
displayName: (c.count && Number(c.count) > 1) ? `${c.name} (x${c.count})` : c.name
}))
})
const selectedCard = computed(() => {
if (cardIndex.value >= 0 && props.propCards[cardIndex.value]) {
return props.propCards[cardIndex.value]
if (cardIndex.value >= 0 && displayCards.value[cardIndex.value]) {
return displayCards.value[cardIndex.value]
}
return null
})
watch(() => props.visible, (val) => {
if (val) {
couponIndex.value = -1
cardIndex.value = -1
}
const finalPayAmount = computed(() => {
const amt = Number(props.amount) || 0
return Math.max(0, amt - effectiveCouponDiscount.value).toFixed(2)
})
watch(
[() => props.visible, () => (Array.isArray(props.coupons) ? props.coupons.length : 0)],
([vis, len]) => {
if (!vis) return
cardIndex.value = -1
if (len <= 0) {
couponIndex.value = -1
return
}
if (couponIndex.value < 0) {
couponIndex.value = 0
}
},
{ immediate: true }
)
function onCouponChange(e) {
couponIndex.value = e.detail.value
}
@ -112,7 +288,7 @@ function onCardChange(e) {
function openAgreement() {
uni.navigateTo({
url: '/pages/agreement/purchase' //
url: '/pages-user/agreement/purchase'
})
}
@ -127,15 +303,330 @@ function handleClose() {
function handleConfirm() {
emit('confirm', {
coupon: selectedCoupon.value,
card: props.showCards ? selectedCard.value : null
coupon: useGamePass.value ? null : selectedCoupon.value,
card: (props.showCards && !useGamePass.value) ? selectedCard.value : null,
useGamePass: useGamePass.value
})
}
</script>
<style lang="scss" scoped>
/* ============================================
奇盒潮玩 - 支付弹窗组件
祝福动画样式
============================================ */
.blessing-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
padding: 20rpx;
}
.blessing-animation {
text-align: center;
animation: blessingFadeIn 0.5s ease-out;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(255, 248, 243, 0.98));
padding: 40rpx 30rpx;
border-radius: 24rpx;
box-shadow: 0 12rpx 48rpx rgba(255, 107, 0, 0.3);
backdrop-filter: blur(20rpx);
border: 2rpx solid rgba(255, 159, 67, 0.3);
}
@keyframes blessingFadeIn {
from {
opacity: 0;
transform: translateY(-30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.blessing-emoji {
font-size: 100rpx;
line-height: 1;
margin-bottom: 16rpx;
display: block;
}
// -
.blessing-animation.sheep .blessing-emoji {
animation: emojiBounce 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
// -
.blessing-animation.horse .blessing-emoji {
animation: emojiRun 1.5s ease-out;
}
// -
.blessing-animation.orange .blessing-emoji {
animation: emojiRotate 1.5s ease-out;
}
// -
.blessing-animation.monkey .blessing-emoji {
animation: emojiSwing 1.5s ease-out;
}
// -
.blessing-animation.ox .blessing-emoji {
animation: emojiCharge 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
// -
.blessing-animation.dog .blessing-emoji {
animation: emojiWag 1.5s ease-in-out;
}
// -
.blessing-animation.chicken .blessing-emoji {
animation: emojiPeck 1.5s ease-in-out;
}
@keyframes emojiBounce {
0% {
transform: scale(0) rotate(-180deg);
opacity: 0;
}
50% {
transform: scale(1.2) rotate(10deg);
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
@keyframes emojiRun {
0% {
transform: translateX(-300rpx) scale(0.8);
opacity: 0;
}
60% {
transform: translateX(30rpx) scale(1.1);
}
80% {
transform: translateX(-15rpx) scale(0.95);
}
100% {
transform: translateX(0) scale(1);
opacity: 1;
}
}
@keyframes emojiRotate {
0% {
transform: scale(0) rotate(0deg);
opacity: 0;
}
40% {
transform: scale(1.2) rotate(180deg);
opacity: 1;
}
60% {
transform: scale(0.95) rotate(360deg);
}
80% {
transform: scale(1.05) rotate(360deg);
}
100% {
transform: scale(1) rotate(360deg);
opacity: 1;
}
}
@keyframes emojiSwing {
0% {
transform: scale(0) translateY(-50rpx) rotate(-30deg);
opacity: 0;
}
40% {
transform: scale(1.15) translateY(10rpx) rotate(20deg);
opacity: 1;
}
60% {
transform: scale(0.9) translateY(-5rpx) rotate(-10deg);
}
80% {
transform: scale(1.05) translateY(3rpx) rotate(5deg);
}
100% {
transform: scale(1) translateY(0) rotate(0deg);
opacity: 1;
}
}
@keyframes emojiCharge {
0% {
transform: scale(0) translateX(-100rpx) rotate(45deg);
opacity: 0;
}
50% {
transform: scale(1.3) translateX(20rpx) rotate(-20deg);
opacity: 1;
}
70% {
transform: scale(0.85) translateX(-10rpx) rotate(10deg);
}
85% {
transform: scale(1.08) translateX(5rpx) rotate(-5deg);
}
100% {
transform: scale(1) translateX(0) rotate(0deg);
opacity: 1;
}
}
@keyframes emojiWag {
0% {
transform: scale(0) translateY(-30rpx) rotate(-15deg);
opacity: 0;
}
30% {
transform: scale(1.2) translateY(0) rotate(15deg);
opacity: 1;
}
50% {
transform: scale(0.9) translateY(-15rpx) rotate(-15deg);
}
70% {
transform: scale(1.1) translateY(0) rotate(15deg);
}
85% {
transform: scale(0.95) translateY(-5rpx) rotate(-5deg);
}
100% {
transform: scale(1) translateY(0) rotate(0deg);
opacity: 1;
}
}
@keyframes emojiPeck {
0% {
transform: scale(0) translateY(-40rpx) rotate(0deg);
opacity: 0;
}
25% {
transform: scale(1.15) translateY(10rpx) rotate(10deg);
opacity: 1;
}
40% {
transform: scale(0.85) translateY(-5rpx) rotate(-10deg);
}
55% {
transform: scale(1.1) translateY(8rpx) rotate(8deg);
}
70% {
transform: scale(0.9) translateY(-3rpx) rotate(-8deg);
}
85% {
transform: scale(1.05) translateY(2rpx) rotate(3deg);
}
100% {
transform: scale(1) translateY(0) rotate(0deg);
opacity: 1;
}
}
.blessing-subtitle {
font-size: 28rpx;
color: #FF9500;
font-weight: 700;
margin-top: 12rpx;
margin-bottom: 8rpx;
opacity: 0;
animation: subtitleFadeIn 0.5s ease-out 0.3s forwards;
}
@keyframes subtitleFadeIn {
from {
opacity: 0;
transform: translateY(10rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.blessing-text {
display: flex;
justify-content: center;
gap: 12rpx;
margin-top: 16rpx;
.char {
font-size: 48rpx;
font-weight: 900;
color: #FF6B00;
text-shadow: 0 4rpx 12rpx rgba(255, 107, 0, 0.3);
opacity: 0;
animation: charAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.char.from-left {
animation-name: charAppearFromLeft;
}
.char.from-right {
animation-name: charAppearFromRight;
}
}
@keyframes charAppear {
0% {
opacity: 0;
transform: translateY(30rpx) scale(0.5);
}
60% {
transform: translateY(-8rpx) scale(1.1);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes charAppearFromLeft {
0% {
opacity: 0;
transform: translateX(-80rpx) scale(0.5);
}
60% {
transform: translateX(10rpx) scale(1.1);
}
100% {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes charAppearFromRight {
0% {
opacity: 0;
transform: translateX(80rpx) scale(0.5);
}
60% {
transform: translateX(-10rpx) scale(1.1);
}
100% {
opacity: 1;
transform: translateX(0) scale(1);
}
}
/* ============================================
柯大鸭潮玩 - 支付弹窗组件
采用暖橙色调的底部弹窗设计
============================================ */
@ -159,7 +650,9 @@ function handleConfirm() {
padding-bottom: calc($spacing-lg + constant(safe-area-inset-bottom));
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
@ -250,6 +743,11 @@ function handleConfirm() {
margin-bottom: $spacing-xs;
}
.picker-full {
width: 100%;
display: block;
}
.picker-display {
border: 2rpx solid $border-color-light;
border-radius: $radius-md;
@ -317,4 +815,120 @@ function handleConfirm() {
transform: scale(0.97);
box-shadow: $shadow-md;
}
/* 次数卡使用按钮特殊样式 */
.btn-game-pass {
background: linear-gradient(135deg, #10B981, #059669);
}
/* ============================================
次数卡选项样式
============================================ */
.game-pass-section {
margin-bottom: $spacing-md;
}
.game-pass-option {
display: flex;
align-items: center;
padding: $spacing-md;
background: linear-gradient(135deg, #ECFDF5, #D1FAE5);
border: 2rpx solid #10B981;
border-radius: $radius-lg;
transition: all 0.2s ease;
&.active {
background: linear-gradient(135deg, #10B981, #059669);
border-color: #059669;
.game-pass-label, .game-pass-count {
color: #FFFFFF;
}
.game-pass-radio {
background: #FFFFFF;
border-color: #FFFFFF;
}
.radio-checked {
color: #10B981;
}
}
&.disabled {
background: #F9FAFB;
border-color: #E5E7EB;
.game-pass-radio {
border-color: #D1D5DB;
background: #F3F4F6;
}
}
}
.game-pass-radio {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 3rpx solid #10B981;
display: flex;
align-items: center;
justify-content: center;
margin-right: $spacing-sm;
}
.radio-checked {
font-size: 24rpx;
font-weight: bold;
color: #10B981;
}
.game-pass-info {
flex: 1;
display: flex;
flex-direction: column;
}
.game-pass-label {
font-size: $font-md;
font-weight: 600;
color: #059669;
}
.game-pass-count {
font-size: $font-sm;
color: #10B981;
margin-top: 4rpx;
}
.divider-line {
display: flex;
align-items: center;
margin-top: $spacing-md;
&::before, &::after {
content: '';
flex: 1;
height: 1rpx;
background: $border-color-light;
}
}
.divider-text {
font-size: $font-xs;
color: $text-placeholder;
padding: 0 $spacing-sm;
}
.radio-disabled {
width: 24rpx;
height: 24rpx;
background: #D1D5DB;
border-radius: 50%;
}
.text-disabled {
color: #9CA3AF !important;
}
</style>

204
components/SplashScreen.vue Normal file
View File

@ -0,0 +1,204 @@
<template>
<view v-if="visible" class="splash-screen" :class="{ 'fade-out': fadingOut }">
<view class="splash-content">
<view class="logo-wrapper">
<image class="logo-img" :src="logoUrl" mode="aspectFit" />
</view>
<view class="slogan-wrapper">
<text class="slogan-text">{{ sloganText }}</text>
</view>
<view class="loading-dots">
<view class="dot"></view>
<view class="dot"></view>
<view class="dot"></view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const visible = ref(false)
const fadingOut = ref(false)
const sloganText = ref('没有套路的真盲盒,就在柯大鸭')
const logoUrl = ref('/static/logo.png')
onMounted(() => {
// YYYY-MM-DD
const today = new Date()
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
// JSON
const splashKey = 'splash_count'
let splashData = uni.getStorageSync(splashKey)
//
if (!splashData || typeof splashData !== 'object') {
splashData = {}
}
//
const cleanedSplashData = {}
if (splashData[dateStr]) {
cleanedSplashData[dateStr] = splashData[dateStr]
}
splashData = cleanedSplashData
//
const todayCount = splashData[dateStr] || 0
// 10
if (todayCount < 10) {
//
visible.value = true
//
splashData[dateStr] = todayCount + 1
uni.setStorageSync(splashKey, splashData)
// 5
setTimeout(() => {
fadingOut.value = true
// 0.6
setTimeout(() => {
visible.value = false
}, 600)
}, 5000)
}
})
</script>
<style lang="scss" scoped>
.splash-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
background: linear-gradient(135deg, #FF6B00 0%, #FF9500 100%);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.6s ease-out, visibility 0.6s ease-out;
&.fade-out {
opacity: 0;
visibility: hidden;
}
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 60rpx;
animation: splashContentIn 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes splashContentIn {
from {
opacity: 0;
transform: scale(0.8) translateY(40rpx);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.logo-wrapper {
margin-bottom: 60rpx;
animation: logoFloat 2s ease-in-out infinite;
.logo-img {
width: 200rpx;
height: 200rpx;
border-radius: 40rpx;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.2);
}
}
@keyframes logoFloat {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-15rpx);
}
}
.slogan-wrapper {
margin-bottom: 80rpx;
text-align: center;
.slogan-text {
font-size: 44rpx;
font-weight: 900;
color: #ffffff;
letter-spacing: 2rpx;
line-height: 1.5;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
animation: sloganFadeIn 1s ease-out 0.3s both;
}
}
@keyframes sloganFadeIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.loading-dots {
display: flex;
gap: 16rpx;
animation: dotsFadeIn 0.6s ease-out 0.6s both;
.dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.1);
animation: dotBounce 1.4s ease-in-out infinite;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes dotsFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dotBounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@ -1,5 +1,12 @@
<template>
<view class="choice-grid-container">
<!-- 调试信息 -->
<view style="background: #4caf50; padding: 20rpx; margin: 10rpx;">
<text style="color: white;"> YifanSelector Component Rendered!</text>
<text style="color: white; display: block;">activityId: {{ activityId }}</text>
<text style="color: white; display: block;">issueId: {{ issueId }}</text>
</view>
<view v-if="loading" class="loading-state">加载中...</view>
<view v-else-if="!choices || choices.length === 0" class="empty-state">暂无可选位置</view>
@ -25,7 +32,7 @@
</view>
</view>
<view class="action-bar">
<view class="action-bar" v-if="!hideActionBar">
<view class="selection-info" v-if="selectedItems.length > 0">
已选 <text class="highlight">{{ selectedItems.length }}</text> 个位置
</view>
@ -34,42 +41,44 @@
</view>
<view class="action-buttons">
<button v-if="selectedItems.length === 0" class="btn-common btn-random" @tap="handleRandomOne">随机一发</button>
<button v-else class="btn-common btn-buy" @tap="handleBuy">去支付</button>
<button v-if="selectedItems.length === 0" class="btn-common btn-random" @tap="handleRandomOne" :disabled="disabled">随机一发</button>
<button v-else class="btn-common btn-buy" @tap="handleBuy" :disabled="disabled">去支付</button>
</view>
</view>
</view>
<!-- 支付弹窗 -->
<PaymentPopup
v-model:visible="paymentVisible"
:amount="totalAmount"
:coupons="coupons"
:showCards="false"
@confirm="onPaymentConfirm"
/>
<!-- 支付弹窗已移至父组件避免在 scroll-view 内导致定位问题 -->
</view>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { getIssueChoices, getUserCoupons, joinLottery, createWechatOrder, getLotteryResult } from '@/api/appUser'
import PaymentPopup from '@/components/PaymentPopup.vue'
import { requestLotterySubscription } from '@/utils/subscribe'
console.log('[YifanSelector] Script setup running!')
const props = defineProps({
activityId: { type: [String, Number], required: true },
issueId: { type: [String, Number], required: true },
pricePerDraw: { type: Number, default: 0 } //
pricePerDraw: { type: Number, default: 0 },
disabled: { type: Boolean, default: false },
disabledText: { type: String, default: '' },
hideActionBar: { type: Boolean, default: false } //
})
const emit = defineEmits(['payment-success'])
const emit = defineEmits(['payment-success', 'selection-change', 'payment-visible-change', 'payment-amount-change', 'payment-coupons-change'])
const choices = ref([])
const loading = ref(false)
const selectedItems = ref([])
const paymentVisible = ref(false)
//
watch(paymentVisible, (newVal) => {
emit('payment-visible-change', newVal)
})
//
const coupons = ref([])
@ -77,23 +86,65 @@ const totalAmount = computed(() => {
return (selectedItems.value.length * props.pricePerDraw).toFixed(2)
})
//
watch(totalAmount, (newVal) => {
emit('payment-amount-change', newVal)
})
//
watch(coupons, (newVal) => {
emit('payment-coupons-change', newVal)
})
const disabled = computed(() => !!props.disabled)
const disabledMessage = computed(() => props.disabledText || '暂不可下单')
watch(() => props.issueId, (newVal) => {
if (newVal) {
loadChoices()
selectedItems.value = []
}
}, { immediate: true }) // immediate
// activityId
watch(() => props.activityId, (newVal) => {
if (newVal && props.issueId) {
loadChoices()
}
})
watch(() => props.disabled, (v) => {
if (v && paymentVisible.value) {
paymentVisible.value = false
}
})
onMounted(() => {
console.log('[YifanSelector] Component mounted', {
activityId: props.activityId,
issueId: props.issueId
})
if (props.issueId) {
loadChoices()
}
})
async function loadChoices() {
console.log('[YifanSelector] loadChoices called with:', {
activityId: props.activityId,
issueId: props.issueId
})
if (!props.activityId || !props.issueId) {
console.warn('[YifanSelector] Missing activityId or issueId, skipping loadChoices')
return
}
loading.value = true
try {
console.log('[YifanSelector] Calling getIssueChoices API...')
const res = await getIssueChoices(props.activityId, props.issueId)
console.log('[YifanSelector] getIssueChoices response:', res)
// { total_slots: 1, available: [1], claimed: [] }
if (res && typeof res.total_slots === 'number' && Array.isArray(res.available)) {
@ -124,6 +175,7 @@ async function loadChoices() {
} else {
choices.value = []
}
console.log('[YifanSelector] Choices processed, total:', choices.value.length)
} catch (error) {
console.error('Failed to load choices:', error)
uni.showToast({ title: '加载位置失败', icon: 'none' })
@ -137,6 +189,10 @@ function isSelected(item) {
}
function handleSelect(item) {
if (disabled.value) {
uni.showToast({ title: disabledMessage.value, icon: 'none' })
return
}
if (item.status === 'sold' || item.is_sold) {
return
}
@ -147,15 +203,29 @@ function handleSelect(item) {
} else {
selectedItems.value.push(item)
}
//
emit('selection-change', [...selectedItems.value])
}
function handleBuy() {
async function handleBuy() {
if (disabled.value) {
uni.showToast({ title: disabledMessage.value, icon: 'none' })
return
}
if (selectedItems.value.length === 0) return
//
emit('payment-amount-change', totalAmount.value)
await fetchCoupons()
paymentVisible.value = true
fetchCoupons()
}
function handleRandomOne() {
async function handleRandomOne() {
if (disabled.value) {
uni.showToast({ title: disabledMessage.value, icon: 'none' })
return
}
const available = choices.value.filter(item =>
!item.is_sold && item.status !== 'sold' && !isSelected(item)
)
@ -171,9 +241,10 @@ function handleRandomOne() {
//
selectedItems.value.push(randomItem)
//
//
emit('payment-amount-change', totalAmount.value)
await fetchCoupons()
paymentVisible.value = true
fetchCoupons()
}
@ -195,13 +266,21 @@ async function fetchCoupons() {
amount: Number(yuan).toFixed(2)
}
})
//
emit('payment-coupons-change', coupons.value)
} catch (e) {
console.error('fetchCoupons error', e)
coupons.value = []
emit('payment-coupons-change', [])
}
}
async function onPaymentConfirm(paymentData) {
if (disabled.value) {
paymentVisible.value = false
uni.showToast({ title: disabledMessage.value, icon: 'none' })
return
}
paymentVisible.value = false
const selectedSlots = selectedItems.value.map(item => item.id || item.position)
@ -230,6 +309,7 @@ async function onPaymentConfirm(paymentData) {
channel: 'miniapp',
count: selectedSlots.length,
coupon_id: paymentData.coupon ? Number(paymentData.coupon.id) : 0,
use_game_pass: !!paymentData.useGamePass,
slot_index: selectedSlots.map(Number)
}
@ -242,24 +322,29 @@ async function onPaymentConfirm(paymentData) {
}
// 2. 使
const payRes = await createWechatOrder({
openid: openid,
order_no: orderNo
})
// Check if order is already paid (e.g. via Game Pass or Points)
const isPaid = (joinRes?.status === 2) || (joinRes?.actual_amount <= 0)
//
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
if (!isPaid) {
const payRes = await createWechatOrder({
openid: openid,
order_no: orderNo
})
//
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
}
uni.hideLoading()
uni.showLoading({ title: '查询结果...' })
@ -292,11 +377,22 @@ async function onPaymentConfirm(paymentData) {
}
}
}
//
defineExpose({
handleRandomOne,
handleBuy,
onPaymentConfirm,
setPaymentVisible: (visible) => {
paymentVisible.value = visible
},
selectedItems: () => selectedItems.value
})
</script>
<style lang="scss" scoped>
/* ============================================
奇盒潮玩 - 选号组件 (适配高级卡片布局)
柯大鸭潮玩 - 选号组件 (适配高级卡片布局)
============================================ */
/* 容器 - 去除背景,融入父级卡片 */
@ -326,8 +422,7 @@ async function onPaymentConfirm(paymentData) {
/* 网格包装 */
.grid-wrapper {
padding-bottom: 200rpx; /* 留出底部操作栏空间 */
padding: 0 20rpx 200rpx;
padding: 0 20rpx 140rpx; /* 减少底部padding */
}
/* 号码网格 - 调整为更合理的列数,适配不同屏幕 */
@ -448,39 +543,40 @@ async function onPaymentConfirm(paymentData) {
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
}
/* ============= 底部操作栏 ============= */
/* ============= 底部操作栏 - 对对碰风格胶囊浮动 ============= */
.action-bar {
position: fixed;
left: 32rpx;
right: 32rpx;
bottom: calc(40rpx + env(safe-area-inset-bottom));
left: 30rpx;
right: 30rpx;
background: rgba($bg-card, 0.9);
backdrop-filter: blur(20rpx);
padding: 20rpx 30rpx;
box-shadow: $shadow-lg;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(30rpx);
padding: 24rpx 40rpx;
border-radius: 999rpx;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
z-index: 100;
border: 1rpx solid rgba($bg-card, 0.5);
animation: slideUp 0.4s ease-out backwards;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.6);
animation: slideUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) backwards;
}
/* 选择信息行 */
.selection-info {
font-size: 26rpx;
font-size: 28rpx;
color: $text-main;
display: flex;
align-items: center;
font-weight: 600;
align-items: baseline;
font-weight: 800;
}
.highlight {
color: $brand-primary;
font-weight: 800;
font-size: 36rpx;
font-weight: 900;
font-size: 40rpx;
margin: 0 8rpx;
font-family: 'DIN Alternate', sans-serif;
}
/* 按钮组 */
@ -491,54 +587,79 @@ async function onPaymentConfirm(paymentData) {
/* 通用按钮样式 */
.btn-common {
height: 80rpx;
line-height: 80rpx;
padding: 0 48rpx;
height: 88rpx;
line-height: 88rpx;
padding: 0 56rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
font-size: 30rpx;
font-weight: 900;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border: none;
&::after {
border: none;
}
&:active {
transform: scale(0.96);
transform: scale(0.92);
}
}
/* 购买按钮 */
/* 购买按钮 - 品牌渐变 + 流光 */
.btn-buy {
background: $gradient-brand !important;
color: #FFFFFF !important;
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.3);
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35);
position: relative;
overflow: hidden;
/* 脉冲动画 */
animation: pulse 2s infinite;
}
/* 随机按钮 */
.btn-random {
background: $bg-secondary !important;
color: $text-main !important;
box-shadow: none;
border: 1rpx solid transparent;
&:active {
background: #E5E7EB !important;
&::before {
content: '';
position: absolute;
top: -50%;
left: -150%;
width: 200%;
height: 200%;
background: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0) 70%
);
transform: rotate(25deg);
animation: btnShine 4s infinite cubic-bezier(0.19, 1, 0.22, 1);
pointer-events: none;
}
}
/* 随机按钮 - 轻量化设计 */
.btn-random {
background: #1A1A1A !important;
color: $accent-gold !important;
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.15);
&:active {
background: #333 !important;
}
}
@keyframes btnShine {
0% { left: -150%; }
100% { left: 150%; }
}
@keyframes slideUp {
from { transform: translateY(100%); opacity: 0; }
from { transform: translateY(120rpx); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba($brand-primary, 0.4); }
70% { box-shadow: 0 0 0 20rpx rgba($brand-primary, 0); }
100% { box-shadow: 0 0 0 0 rgba($brand-primary, 0); }
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes float {

View File

@ -0,0 +1,262 @@
<template>
<view class="header-card animate-enter">
<image class="header-cover" :src="coverUrl" mode="aspectFill" />
<view class="header-info">
<view class="header-title">{{ title }}</view>
<view class="header-price-row" v-if="price !== undefined">
<text class="price-symbol">¥</text>
<text class="price-num">{{ formattedPrice }}</text>
<text class="price-unit">{{ priceUnit }}</text>
</view>
<view class="header-time-row" v-if="scheduledTime">
<text class="time-label">本期结束</text>
<text class="time-value">{{ scheduledTime }}</text>
</view>
<view class="header-tags" v-if="tags && tags.length">
<view class="tag-item" v-for="(tag, idx) in tags" :key="idx">{{ tag }}</view>
</view>
</view>
<view class="header-actions">
<view class="action-btn" @tap="$emit('show-rules')">
<view class="action-icon rules-icon"></view>
<text class="action-label">规则</text>
</view>
<view class="action-btn" @tap="$emit('go-cabinet')">
<view class="action-icon cabinet-icon"></view>
<text class="action-label">盒柜</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
title: {
type: String,
default: ''
},
price: {
type: Number,
default: undefined
},
priceUnit: {
type: String,
default: '/发'
},
coverUrl: {
type: String,
default: ''
},
tags: {
type: Array,
default: () => []
},
scheduledTime: {
type: String,
default: ''
}
})
defineEmits(['show-rules', 'go-cabinet'])
const formattedPrice = computed(() => {
const cents = Number(props.price || 0)
return (cents / 100).toFixed(2)
})
</script>
<style lang="scss" scoped>
/* ============================================
头部卡片 - 与原始设计完全一致
============================================ */
.header-card {
margin: $spacing-xl $spacing-lg;
background: rgba($bg-card, 0.72);
backdrop-filter: blur(32rpx);
border-radius: $radius-xl;
padding: $spacing-lg;
display: flex;
align-items: center;
box-shadow:
0 1rpx 0 rgba(255,255,255,0.5) inset,
0 -1rpx 0 rgba(0,0,0,0.02) inset,
$shadow-card;
border: none;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2rpx;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.8), transparent);
}
}
.header-cover {
width: 180rpx;
height: 180rpx;
border-radius: $radius-md;
margin-right: $spacing-lg;
background: $bg-secondary;
box-shadow: $shadow-md;
flex-shrink: 0;
}
.header-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: 6rpx 0;
}
.header-title {
font-size: $font-xl;
font-weight: 800;
color: $text-main;
margin-bottom: $spacing-xs;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.header-price-row {
display: flex;
align-items: baseline;
color: $brand-primary;
margin-bottom: $spacing-sm;
text-shadow: 0 2rpx 4rpx rgba($brand-primary, 0.1);
}
.price-symbol {
font-size: $font-md;
font-weight: 700;
}
.price-num {
font-size: $font-xxl;
font-weight: 900;
margin: 0 4rpx;
font-family: 'DIN Alternate', sans-serif;
}
.price-unit {
font-size: $font-sm;
color: $text-sub;
margin-left: 4rpx;
}
.header-time-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: $spacing-sm;
}
.time-label {
font-size: $font-xs;
color: $text-tertiary;
font-weight: 600;
}
.time-value {
font-size: $font-sm;
color: $text-sub;
font-weight: 600;
}
.header-tags {
display: flex;
gap: $spacing-xs;
flex-wrap: wrap;
}
.tag-item {
font-size: $font-xs;
color: $brand-primary-dark;
background: rgba($brand-primary, 0.08);
padding: 4rpx $spacing-sm;
border-radius: $radius-sm;
font-weight: 600;
border: 1rpx solid rgba($brand-primary, 0.1);
}
.header-actions {
display: flex;
flex-direction: column;
gap: 28rpx;
margin-left: 16rpx;
padding-left: 24rpx;
border-left: 2rpx solid #E8E8E8;
justify-content: center;
align-self: stretch;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
&:active {
opacity: 0.6;
}
}
.action-icon {
width: 44rpx;
height: 44rpx;
margin-bottom: 8rpx;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.rules-icon {
background-color: #999;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E");
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z'/%3E%3C/svg%3E");
mask-size: cover;
-webkit-mask-size: cover;
}
.cabinet-icon {
background-color: #999;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 3H4c-1.1 0-2 .9-2 2v16l4-4h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 11H7v-2h4v2zm6-4H7V8h10v2z'/%3E%3C/svg%3E");
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 3H4c-1.1 0-2 .9-2 2v16l4-4h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 11H7v-2h4v2zm6-4H7V8h10v2z'/%3E%3C/svg%3E");
mask-size: cover;
-webkit-mask-size: cover;
}
.action-label {
font-size: 22rpx;
color: #666;
letter-spacing: 1rpx;
}
/* 入场动画 */
.animate-enter {
animation: slideUp 0.5s ease-out both;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,147 @@
<template>
<view class="page-wrapper">
<!-- 背景装饰 - 与原始设计一致 -->
<view class="bg-decoration">
<view class="orb orb-1"></view>
<view class="orb orb-2"></view>
</view>
<!-- 顶部背景图模糊处理 -->
<view class="page-bg">
<image class="bg-image" :src="coverUrl" mode="aspectFill" />
<view class="bg-mask"></view>
</view>
<!-- 主要内容区域 -->
<scroll-view
class="main-scroll"
scroll-y
:enhanced="true"
:bounces="true"
:show-scrollbar="false"
:fast-deceleration="false"
>
<!-- 头部插槽 -->
<slot name="header"></slot>
<!-- 主内容插槽 -->
<slot name="content"></slot>
<slot></slot>
<!-- 底部垫高 -->
<view :style="{ height: bottomPadding }"></view>
</scroll-view>
<!-- 底部操作栏插槽 -->
<slot name="footer"></slot>
<!-- 弹窗插槽 -->
<slot name="modals"></slot>
</view>
</template>
<script setup>
defineProps({
coverUrl: {
type: String,
default: ''
},
bottomPadding: {
type: String,
default: '180rpx'
}
})
</script>
<style lang="scss" scoped>
/* ============================================
页面框架 - 与原始设计完全一致
============================================ */
.page-wrapper {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80rpx);
opacity: 0.6;
}
.orb-1 {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.2) 0%, transparent 70%);
top: -100rpx;
left: -100rpx;
}
.orb-2 {
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.15) 0%, transparent 70%);
bottom: -100rpx;
right: -100rpx;
}
/* 顶部背景 */
.page-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 900rpx;
z-index: 1;
}
.bg-image {
width: 115%;
height: 115%;
max-width: 115%;
max-height: 115%;
position: absolute;
top: -7.5%;
left: -7.5%;
filter: blur(40rpx) brightness(0.85) saturate(1.1);
}
.bg-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* 6段式平滑过渡模拟ease-out曲线 */
background:
linear-gradient(180deg,
rgba($bg-page, 0) 0%,
rgba($bg-page, 0.05) 15%,
rgba($bg-page, 0.2) 35%,
rgba($bg-page, 0.5) 55%,
rgba($bg-page, 0.8) 70%,
$bg-page 82%
);
}
.main-scroll {
position: relative;
z-index: 2;
height: 100vh;
}
</style>

View File

@ -0,0 +1,118 @@
<template>
<view class="section-container animate-enter" :class="staggerClass">
<!-- Modern Tabs - 与原始设计一致 -->
<view class="modern-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ active: modelValue === tab.key }"
@tap="$emit('update:modelValue', tab.key)"
>
{{ tab.label }}
<view v-if="modelValue === tab.key" class="active-dot"></view>
</view>
</view>
<slot :name="modelValue"></slot>
<slot></slot>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: 'pool'
},
tabs: {
type: Array,
default: () => [
{ key: 'pool', label: '本机奖池' },
{ key: 'records', label: '购买记录' }
]
},
stagger: {
type: Number,
default: 1
}
})
defineEmits(['update:modelValue'])
const staggerClass = computed(() => `stagger-${props.stagger}`)
</script>
<style lang="scss" scoped>
/* Section Container - 与原始设计一致 */
.section-container {
margin: 0 $spacing-lg $spacing-lg;
background: rgba(255, 255, 255, 0.78);
border-radius: $radius-xl;
padding: $spacing-lg;
box-shadow:
0 1rpx 0 rgba(255,255,255,0.4) inset,
$shadow-sm;
backdrop-filter: blur(16rpx);
}
/* Modern Tabs - 与原始设计完全一致 */
.modern-tabs {
display: flex;
background: $bg-secondary;
padding: 8rpx;
border-radius: $radius-lg;
margin-bottom: $spacing-lg;
}
.tab-item {
flex: 1;
text-align: center;
padding: $spacing-md 0;
font-size: $font-md;
color: $text-sub;
border-radius: $radius-md;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&.active {
background: #FFFFFF;
color: $brand-primary;
box-shadow: $shadow-sm;
}
}
.active-dot {
width: 8rpx;
height: 8rpx;
background: $brand-primary;
border-radius: 50%;
position: absolute;
bottom: 8rpx;
left: 50%;
transform: translateX(-50%);
}
/* 入场动画 */
.animate-enter {
animation: slideUp 0.5s ease-out both;
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,228 @@
<template>
<view v-if="visible" class="cabinet-overlay" @touchmove.stop.prevent>
<view class="cabinet-mask" @tap="close"></view>
<view class="cabinet-panel" @tap.stop>
<view class="cabinet-header">
<text class="cabinet-title">我的盒柜</text>
<view class="cabinet-actions">
<text class="view-all" @tap="goFullCabinet">查看全部</text>
<text class="cabinet-close" @tap="close">×</text>
</view>
</view>
<view v-if="loading" class="cabinet-loading">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="items.length === 0" class="cabinet-empty">
<text class="empty-text">暂无物品参与活动获取奖品</text>
</view>
<scroll-view v-else scroll-x class="cabinet-scroll">
<view class="thumb-list">
<view v-for="item in displayItems" :key="item.id" class="thumb-item">
<image class="thumb-img" :src="item.image" mode="aspectFill" />
<text class="thumb-count">x{{ item.count }}</text>
</view>
<view v-if="hasMore" class="thumb-more" @tap="goFullCabinet">
<text>+{{ items.length - maxDisplay }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getInventory } from '@/api/appUser'
const props = defineProps({
visible: { type: Boolean, default: false },
activityId: { type: [String, Number], default: '' }
})
const emit = defineEmits(['update:visible'])
const loading = ref(false)
const items = ref([])
const total = ref(0)
const maxDisplay = 8
const displayItems = computed(() => items.value.slice(0, maxDisplay))
const hasMore = computed(() => items.value.length > maxDisplay)
function close() { emit('update:visible', false) }
function goFullCabinet() {
close()
uni.switchTab({ url: '/pages/cabinet/index' })
}
function cleanUrl(u) {
if (!u) return '/static/logo.png'
let s = String(u).trim()
if (s.startsWith('[') && s.endsWith(']')) {
try { const arr = JSON.parse(s); if (Array.isArray(arr) && arr.length > 0) s = arr[0] } catch (e) {}
}
s = s.replace(/[`'"]/g, '').trim()
const m = s.match(/https?:\/\/[^\s]+/)
if (m && m[0]) return m[0]
return s || '/static/logo.png'
}
async function loadItems() {
loading.value = true
try {
const userId = uni.getStorageSync('user_id')
if (!userId) { items.value = []; total.value = 0; return }
const res = await getInventory(userId, 1, 50, { status: 1 })
let list = []
let rawTotal = 0
if (res && Array.isArray(res.list)) { list = res.list; rawTotal = res.total || 0 }
else if (res && Array.isArray(res.data)) { list = res.data; rawTotal = res.total || 0 }
else if (Array.isArray(res)) { list = res; rawTotal = res.length }
// status=1
// status=1
const displayRes = list.map(item => ({
id: item.product_id,
name: (item.product_name || '未知商品').trim(),
image: cleanUrl(item.product_images || item.image),
count: item.count
}))
items.value = displayRes
total.value = rawTotal
} catch (e) {
console.error('[CabinetPreviewPopup] 加载失败', e)
items.value = []
total.value = 0
} finally {
loading.value = false
}
}
watch(() => props.visible, (v) => { if (v) loadItems() })
</script>
<style lang="scss" scoped>
.cabinet-overlay {
position: fixed;
left: 0; right: 0; top: 0; bottom: 0;
z-index: 9000;
}
.cabinet-mask {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.cabinet-panel {
position: absolute;
left: 24rpx; right: 24rpx;
bottom: calc(env(safe-area-inset-bottom) + 24rpx);
background: rgba(255, 255, 255, 0.95);
border-radius: 24rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
overflow: hidden;
animation: slideUp 0.2s ease-out;
}
.cabinet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
}
.cabinet-title {
font-size: 28rpx;
font-weight: 700;
color: #333;
}
.cabinet-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.view-all {
font-size: 24rpx;
color: #FF6B35;
font-weight: 600;
}
.cabinet-close {
font-size: 40rpx;
line-height: 1;
color: #999;
padding: 0 8rpx;
}
.cabinet-loading, .cabinet-empty {
padding: 32rpx 24rpx;
text-align: center;
}
.loading-text, .empty-text {
font-size: 24rpx;
color: #999;
}
.cabinet-scroll {
white-space: nowrap;
padding: 20rpx 24rpx;
}
.thumb-list {
display: inline-flex;
gap: 16rpx;
}
.thumb-item {
position: relative;
flex-shrink: 0;
}
.thumb-img {
width: 100rpx;
height: 100rpx;
border-radius: 12rpx;
background: #f5f5f5;
}
.thumb-count {
position: absolute;
right: 4rpx;
bottom: 4rpx;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 20rpx;
padding: 2rpx 8rpx;
border-radius: 8rpx;
font-weight: 600;
}
.thumb-more {
width: 100rpx;
height: 100rpx;
border-radius: 12rpx;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #666;
font-weight: 600;
flex-shrink: 0;
}
@keyframes slideUp {
from { transform: translateY(20rpx); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>

View File

@ -0,0 +1,319 @@
<template>
<view v-if="visible" class="draw-loading-overlay" @touchmove.stop.prevent>
<!-- 背景渐变 -->
<view class="bg-gradient"></view>
<!-- 光圈效果 -->
<view class="light-ring"></view>
<view class="light-ring ring-2"></view>
<!-- 主内容 -->
<view class="loading-content">
<!-- 3D礼盒动画 -->
<view class="gift-container">
<view class="gift-box">
<view class="gift-lid">
<view class="lid-top"></view>
<view class="lid-ribbon"></view>
</view>
<view class="gift-body">
<view class="body-ribbon"></view>
</view>
</view>
<!-- 闪光粒子 -->
<view class="sparkle sparkle-1"></view>
<view class="sparkle sparkle-2"></view>
<view class="sparkle sparkle-3"></view>
<view class="sparkle sparkle-4">💫</view>
</view>
<!-- 文字区域 -->
<view class="text-area">
<text class="loading-title">{{ title }}</text>
<view class="loading-dots">
<view class="dot"></view>
<view class="dot"></view>
<view class="dot"></view>
</view>
</view>
<!-- 进度条当有多次抽奖时显示 -->
<view v-if="total > 1" class="progress-area">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
</view>
<text class="progress-text">{{ progress }} / {{ total }}</text>
</view>
<!-- 提示文字 -->
<text class="tip-text">请稍候好运即将到来...</text>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: '努力拆盒中' },
progress: { type: Number, default: 0 },
total: { type: Number, default: 1 }
})
const progressPercent = computed(() => {
if (props.total <= 0) return 0
return Math.min(100, Math.round((props.progress / props.total) * 100))
})
</script>
<style lang="scss" scoped>
.draw-loading-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 背景 */
.bg-gradient {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center,
rgba(255, 140, 0, 0.15) 0%,
rgba(30, 20, 50, 0.98) 50%,
rgba(10, 5, 20, 0.99) 100%
);
}
/* 光圈 */
.light-ring {
position: absolute;
width: 500rpx; height: 500rpx;
border: 4rpx solid rgba(255, 200, 100, 0.3);
border-radius: 50%;
animation: ringExpand 2s ease-out infinite;
}
.ring-2 {
animation-delay: 1s;
}
@keyframes ringExpand {
0% {
transform: scale(0.5);
opacity: 0.8;
border-width: 8rpx;
}
100% {
transform: scale(2);
opacity: 0;
border-width: 2rpx;
}
}
/* 主内容 */
.loading-content {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
animation: contentPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes contentPop {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
/* 礼盒容器 */
.gift-container {
position: relative;
width: 240rpx;
height: 240rpx;
margin-bottom: 60rpx;
}
/* 礼盒动画 */
.gift-box {
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
animation: boxBounce 1.5s ease-in-out infinite;
}
@keyframes boxBounce {
0%, 100% { transform: translate(-50%, -50%); }
50% { transform: translate(-50%, -60%); }
}
.gift-lid {
position: relative;
animation: lidShake 1.5s ease-in-out infinite;
transform-origin: center bottom;
}
@keyframes lidShake {
0%, 100% { transform: rotate(0deg) translateY(0); }
25% { transform: rotate(-5deg) translateY(-10rpx); }
75% { transform: rotate(5deg) translateY(-10rpx); }
}
.lid-top {
width: 140rpx; height: 30rpx;
background: linear-gradient(135deg, #FF6B35, #FF8C00);
border-radius: 8rpx 8rpx 0 0;
box-shadow: 0 -4rpx 16rpx rgba(255, 107, 53, 0.5);
}
.lid-ribbon {
position: absolute;
left: 50%; top: -20rpx;
transform: translateX(-50%);
width: 40rpx; height: 50rpx;
background: linear-gradient(135deg, #FFD700, #FFA500);
border-radius: 8rpx;
&::before, &::after {
content: '';
position: absolute;
top: 36rpx;
width: 30rpx; height: 30rpx;
background: linear-gradient(135deg, #FFD700, #FFA500);
border-radius: 50%;
}
&::before { left: -20rpx; }
&::after { right: -20rpx; }
}
.gift-body {
width: 120rpx; height: 100rpx;
background: linear-gradient(135deg, #FF8C00, #FF6B35);
border-radius: 0 0 12rpx 12rpx;
margin: 0 auto;
margin-top: -2rpx;
box-shadow:
0 12rpx 32rpx rgba(255, 107, 53, 0.4),
inset 0 -10rpx 20rpx rgba(0,0,0,0.1);
}
.body-ribbon {
width: 30rpx; height: 100%;
background: linear-gradient(180deg, #FFD700, #FFA500);
margin: 0 auto;
}
/* 闪光粒子 */
.sparkle {
position: absolute;
font-size: 32rpx;
animation: sparkleFloat 2s ease-in-out infinite;
}
.sparkle-1 { top: 10rpx; left: 20rpx; animation-delay: 0s; }
.sparkle-2 { top: 30rpx; right: 20rpx; animation-delay: 0.5s; }
.sparkle-3 { bottom: 40rpx; left: 0; animation-delay: 1s; }
.sparkle-4 { bottom: 20rpx; right: 10rpx; animation-delay: 1.5s; }
@keyframes sparkleFloat {
0%, 100% {
opacity: 0.4;
transform: translateY(0) scale(0.8);
}
50% {
opacity: 1;
transform: translateY(-20rpx) scale(1.2);
}
}
/* 文字区域 */
.text-area {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 40rpx;
}
.loading-title {
font-size: 40rpx;
font-weight: 800;
color: #FFF;
text-shadow: 0 0 30rpx rgba(255, 180, 100, 0.8);
letter-spacing: 4rpx;
}
.loading-dots {
display: flex;
gap: 8rpx;
}
.dot {
width: 12rpx; height: 12rpx;
background: #FFD700;
border-radius: 50%;
animation: dotBounce 1.4s ease-in-out infinite;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
@keyframes dotBounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* 进度条 */
.progress-area {
width: 400rpx;
margin-bottom: 30rpx;
}
.progress-bar {
height: 16rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 8rpx;
overflow: hidden;
box-shadow: inset 0 2rpx 4rpx rgba(0,0,0,0.2);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #FFD700, #FF8C00, #FF6B35);
border-radius: 8rpx;
transition: width 0.3s ease-out;
box-shadow: 0 0 16rpx rgba(255, 200, 0, 0.6);
}
.progress-text {
display: block;
text-align: center;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-top: 12rpx;
font-weight: 600;
}
/* 提示文字 */
.tip-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 2rpx;
}
</style>

View File

@ -0,0 +1,502 @@
<template>
<view v-if="visible" class="lottery-overlay" @touchmove.stop.prevent>
<!-- 背景光效 -->
<view class="bg-glow"></view>
<view class="bg-rays"></view>
<!-- 彩带粒子 -->
<view class="confetti-container">
<view v-for="i in 20" :key="i" class="confetti" :style="getConfettiStyle(i)"></view>
</view>
<!-- 主内容区 -->
<view class="lottery-content">
<!-- 中奖标题 -->
<view class="title-area">
<view class="crown-icon">🎉</view>
<text class="main-title">恭喜获得</text>
</view>
<!-- 奖品展示区 -->
<scroll-view scroll-y class="prizes-scroll">
<view class="prizes-grid">
<view
v-for="(item, index) in groupedResults"
:key="index"
class="prize-card"
:style="{ animationDelay: `${0.2 + index * 0.15}s` }"
>
<!-- 光效边框 -->
<view class="card-glow-border"></view>
<!-- 卡片内容 -->
<view class="card-inner">
<view class="qty-badge" v-if="item.quantity > 1">x{{ item.quantity }}</view>
<view class="image-wrap">
<image
v-if="item.image"
class="prize-img"
:src="item.image"
mode="aspectFill"
@tap="previewImage(item.image)"
/>
<view v-else class="prize-placeholder">🎁</view>
</view>
<view class="prize-details">
<text class="prize-name">{{ item.title }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="action-area">
<!-- 如果使用次数卡显示"再来一次"按钮 -->
<view v-if="showRetryButton" class="retry-buttons">
<view class="retry-btn" @tap="handleRetry">
<view class="btn-glow"></view>
<view class="btn-inner">
<text class="btn-icon">🔄</text>
<text class="btn-text">再来一次</text>
</view>
</view>
<view class="secondary-btn" @tap="handleClose">
<text class="btn-text">知道了</text>
</view>
</view>
<!-- 普通情况显示单个按钮 -->
<view v-else class="claim-btn" @tap="handleClose">
<view class="btn-glow"></view>
<view class="btn-inner">
<text class="btn-icon"></text>
<text class="btn-text">知道了</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
results: { type: Array, default: () => [] },
showRetryButton: { type: Boolean, default: false } // ""
})
const emit = defineEmits(['update:visible', 'close', 'retry'])
function cleanUrl(u) {
if (!u) return '/static/logo.png'
let s = String(u).trim()
// JSON ( JSON )
if (s.startsWith('[') && s.endsWith(']')) {
try {
const arr = JSON.parse(s)
if (Array.isArray(arr) && arr.length > 0) {
s = arr[0]
}
} catch (e) {
console.warn('JSON parse failed for prize image:', s)
}
}
//
s = s.replace(/[`'"]/g, '').trim()
// http
const m = s.match(/https?:\/\/[^\s]+/)
if (m && m[0]) return m[0]
return s || '/static/logo.png'
}
const groupedResults = computed(() => {
const map = new Map()
const arr = Array.isArray(props.results) ? props.results : []
arr.forEach(item => {
// 使reward_idkey
const rewardId = item.reward_id || item.rewardId || item.id
const key = rewardId != null ? `rid_${rewardId}` : (item.title || item.name || '神秘奖品')
if (map.has(key)) {
map.get(key).quantity++
} else {
map.set(key, {
title: item.title || item.name || '神秘奖品',
image: cleanUrl(item.image || item.img || item.pic || ''),
reward_id: rewardId,
quantity: 1
})
}
})
return Array.from(map.values())
})
function getConfettiStyle(i) {
const colors = ['#FF6B35', '#FFD93D', '#6BCB77', '#4D96FF', '#FF6B6B', '#C9B1FF']
const left = Math.random() * 100
const delay = Math.random() * 2
const duration = 2 + Math.random() * 2
const size = 8 + Math.random() * 8
return {
left: `${left}%`,
animationDelay: `${delay}s`,
animationDuration: `${duration}s`,
width: `${size}rpx`,
height: `${size * 1.5}rpx`,
background: colors[i % colors.length]
}
}
function handleClose() {
emit('update:visible', false)
emit('close')
}
function handleRetry() {
emit('update:visible', false)
emit('retry')
}
function previewImage(url) {
if (url) uni.previewImage({ urls: [url], current: url })
}
</script>
<style lang="scss" scoped>
.lottery-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at center, rgba(30, 20, 50, 0.95) 0%, rgba(10, 5, 20, 0.98) 100%);
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 背景光效 */
.bg-glow {
position: absolute;
top: 20%; left: 50%;
transform: translateX(-50%);
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba(255, 180, 100, 0.4) 0%, transparent 70%);
filter: blur(60rpx);
animation: pulse 3s ease-in-out infinite;
}
.bg-rays {
position: absolute;
top: 15%; left: 50%;
transform: translateX(-50%);
width: 800rpx; height: 800rpx;
background: conic-gradient(from 0deg, transparent, rgba(255, 200, 100, 0.1), transparent, rgba(255, 200, 100, 0.1), transparent);
animation: rotate 20s linear infinite;
}
@keyframes rotate { to { transform: translateX(-50%) rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.6; transform: translateX(-50%) scale(1); } 50% { opacity: 1; transform: translateX(-50%) scale(1.1); } }
/* 彩带 */
.confetti-container {
position: absolute;
top: 0; left: 0; right: 0;
height: 100%;
overflow: hidden;
pointer-events: none;
}
.confetti {
position: absolute;
top: -20rpx;
border-radius: 4rpx;
animation: confettiFall 3s linear infinite;
}
@keyframes confettiFall {
0% { transform: translateY(-20rpx) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
/* 主内容 */
.lottery-content {
position: relative;
width: 88%;
max-height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
animation: contentPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes contentPop {
from { opacity: 0; transform: scale(0.8) translateY(40rpx); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* 标题区 */
.title-area {
position: relative;
text-align: center;
margin-bottom: 40rpx;
z-index: 10;
}
.crown-icon {
font-size: 80rpx;
display: block;
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10rpx); }
}
.main-title {
font-size: 56rpx;
font-weight: 900;
color: #fff;
text-shadow: 0 0 40rpx rgba(255, 180, 100, 0.8), 0 4rpx 20rpx rgba(0, 0, 0, 0.5);
display: block;
letter-spacing: 8rpx;
}
/* 奖品滚动区 */
.prizes-scroll {
width: 100%;
max-height: 50vh;
padding: 0 10rpx;
}
.prizes-grid {
display: flex;
flex-wrap: wrap;
gap: 24rpx;
justify-content: center;
padding: 20rpx 0;
}
/* 奖品卡片 */
.prize-card {
position: relative;
width: calc(50% - 12rpx);
max-width: 300rpx;
animation: cardReveal 0.6s ease-out backwards;
}
@keyframes cardReveal {
from { opacity: 0; transform: scale(0.8) rotateY(-30deg); }
to { opacity: 1; transform: scale(1) rotateY(0); }
}
.card-glow-border {
position: absolute;
inset: -4rpx;
background: linear-gradient(135deg, #FFD700, #FF8C00, #FFD700, #FF6347, #FFD700);
background-size: 400% 400%;
border-radius: 28rpx;
animation: borderGlow 3s ease infinite;
z-index: 0;
}
@keyframes borderGlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.card-inner {
position: relative;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.98), rgba(255, 248, 240, 0.95));
border-radius: 24rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
z-index: 1;
}
.qty-badge {
position: absolute;
top: -12rpx; right: -12rpx;
background: linear-gradient(135deg, #FF6B35, #FF8C00);
color: #fff;
font-size: 24rpx;
font-weight: 800;
padding: 8rpx 16rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(255, 107, 53, 0.5);
z-index: 10;
}
.image-wrap {
width: 160rpx; height: 160rpx;
border-radius: 16rpx;
overflow: hidden;
background: linear-gradient(145deg, #FFF8F3, #FFE8D1);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
}
.prize-img {
width: 100%; height: 100%;
}
.prize-placeholder {
font-size: 64rpx;
}
.prize-details {
margin-top: 16rpx;
text-align: center;
width: 100%;
}
.prize-name {
font-size: 24rpx;
font-weight: 700;
color: #333;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
/* 底部按钮 - 重新设计 */
.action-area {
width: 100%;
padding: 40rpx 20rpx 20rpx;
}
.claim-btn {
position: relative;
width: 100%;
height: 110rpx;
display: flex;
align-items: center;
justify-content: center;
&:active .btn-inner {
transform: scale(0.96);
}
}
.retry-buttons {
display: flex;
gap: 16rpx;
width: 100%;
}
.retry-btn {
position: relative;
flex: 2;
height: 110rpx;
display: flex;
align-items: center;
justify-content: center;
&:active .btn-inner {
transform: scale(0.96);
}
}
.secondary-btn {
flex: 1;
height: 110rpx;
background: rgba(255, 255, 255, 0.2);
border: 2rpx solid rgba(255, 255, 255, 0.3);
border-radius: 55rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: rgba(255, 255, 255, 0.3);
transform: scale(0.96);
}
.btn-text {
font-size: 30rpx;
font-weight: 700;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
}
}
.btn-glow {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #FFD700, #FF8C00, #FF6B35);
border-radius: 55rpx;
filter: blur(15rpx);
opacity: 0.6;
animation: btnPulse 2s ease-in-out infinite;
}
@keyframes btnPulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.02); }
}
.btn-inner {
position: relative;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #FFD700 0%, #FF8C00 50%, #FF6B35 100%);
border-radius: 55rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
box-shadow:
0 8rpx 32rpx rgba(255, 140, 0, 0.5),
inset 0 2rpx 0 rgba(255, 255, 255, 0.4),
inset 0 -2rpx 0 rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0; left: -100%;
width: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: btnShine 2.5s ease-in-out infinite;
}
}
@keyframes btnShine {
0% { left: -100%; }
50%, 100% { left: 100%; }
}
.btn-icon {
font-size: 36rpx;
}
.btn-text {
font-size: 34rpx;
font-weight: 800;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
letter-spacing: 4rpx;
}
</style>

View File

@ -0,0 +1,239 @@
<template>
<view class="records-wrapper">
<view class="records-list" v-if="records && records.length">
<view v-for="(item, idx) in records" :key="item.id ? `${item.id}_${idx}` : idx" class="record-item">
<!-- 用户信息 (左侧, 紧凑) -->
<view class="user-info-section">
<image class="user-avatar" :src="item.avatar || defaultAvatar" mode="aspectFill" />
<view class="user-detail">
<text class="user-name">{{ item.user_name }}</text>
<text class="record-time">{{ formatTime(item.created_at) }}</text>
</view>
</view>
<!-- 奖品信息 (右侧, 扩展) -->
<view class="prize-info-section">
<view class="prize-image-wrap">
<image class="record-img" :src="item.image" mode="aspectFill" />
<view class="level-badge" v-if="item.level_name">{{ item.level_name }}</view>
</view>
<view class="record-info">
<view class="record-title">{{ item.title }}</view>
<view class="record-meta">
<text class="record-count">x1</text>
</view>
</view>
</view>
</view>
</view>
<view class="empty-state-compact" v-else>
<view class="empty-icon-wrap">
<text class="empty-icon">🎁</text>
</view>
<text class="empty-title">{{ emptyText }}</text>
<text class="empty-hint">快来参与活动获得奖品吧</text>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
defineProps({
records: {
type: Array,
default: () => []
},
emptyText: {
type: String,
default: '暂无购买记录'
}
})
const defaultAvatar = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
if (isNaN(d.getTime())) return t //
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${m}-${day} ${hh}:${mm}:${ss}`
}
</script>
<style lang="scss" scoped>
.records-list {
padding: $spacing-xs 0;
}
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-md $spacing-sm;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
&:last-child {
border-bottom: none;
}
}
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-md $spacing-sm;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
&:last-child {
border-bottom: none;
}
}
.user-info-section {
display: flex;
align-items: center;
gap: $spacing-xs;
flex: 0 0 35%; //
min-width: 0;
}
.user-avatar {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: $bg-secondary;
border: 1px solid rgba(0,0,0,0.05);
flex-shrink: 0;
}
.user-detail {
display: flex;
flex-direction: column;
gap: 2rpx;
min-width: 0;
}
.user-name {
font-size: 24rpx;
color: $text-main;
font-weight: 500;
@include text-ellipsis(1);
}
.record-time {
font-size: 20rpx;
color: $text-sub;
@include text-ellipsis(1);
}
.prize-info-section {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
min-width: 0;
justify-content: flex-end;
}
.prize-image-wrap {
position: relative;
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
}
.record-img {
width: 100%;
height: 100%;
border-radius: $radius-md;
background: $bg-secondary;
border: 1px solid rgba(0,0,0,0.05);
}
.level-badge {
position: absolute;
top: -6rpx;
right: -6rpx;
background: $gradient-gold;
color: #fff;
font-size: 16rpx;
padding: 2rpx 6rpx;
border-radius: 4rpx;
font-weight: bold;
box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
}
.record-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start; //
}
.record-title {
font-size: 24rpx;
font-weight: 500;
color: $text-main;
@include text-ellipsis(2); //
line-height: 1.3;
margin-bottom: 4rpx;
width: 100%;
}
.record-meta {
display: flex;
align-items: center;
}
.record-count {
font-size: 20rpx;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
padding: 2rpx 8rpx;
border-radius: 4rpx;
}
/* 紧凑优雅的空状态 */
.empty-state-compact {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-lg $spacing-xl;
min-height: 200rpx;
}
.empty-icon-wrap {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba($brand-primary, 0.1) 0%, rgba($accent-gold, 0.1) 100%);
border-radius: 50%;
margin-bottom: $spacing-md;
}
.empty-icon {
font-size: 40rpx;
}
.empty-title {
font-size: $font-md;
color: $text-sub;
font-weight: 600;
margin-bottom: 8rpx;
}
.empty-hint {
font-size: $font-xs;
color: $text-tertiary;
}
</style>

View File

@ -0,0 +1,219 @@
<template>
<view v-if="visible" class="rewards-overlay" @touchmove.stop.prevent>
<view class="rewards-mask" @tap="$emit('update:visible', false)"></view>
<view class="rewards-panel" @tap.stop>
<view class="rewards-header">
<text class="rewards-title">{{ title }}</text>
<text class="rewards-close" @tap="$emit('update:visible', false)">×</text>
</view>
<scroll-view scroll-y class="rewards-list">
<view v-if="rewardGroups.length > 0">
<view class="rewards-group-v2" v-for="group in rewardGroups" :key="group.level">
<view class="group-header-row">
<text class="group-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">{{ group.level }}</text>
<text class="group-total-prob">该档总概率 {{ group.totalPercent }}%</text>
</view>
<view v-for="(item, idx) in group.rewards" :key="item.id || idx" class="rewards-item">
<image class="rewards-thumb" :src="item.image" mode="aspectFill" />
<view class="rewards-info">
<view class="rewards-name-row">
<text class="rewards-name">{{ item.title || '-' }}</text>
<view class="rewards-tag" v-if="item.boss">BOSS</view>
</view>
<text class="rewards-percent">单项概率 {{ formatPercent(item.percent) }}</text>
</view>
</view>
</view>
</view>
<view v-else class="rewards-empty">{{ emptyText }}</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { formatPercent } from '@/utils/format'
defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '奖品与概率'
},
rewardGroups: {
type: Array,
default: () => []
},
emptyText: {
type: String,
default: '暂无奖品数据'
}
})
defineEmits(['update:visible'])
</script>
<style lang="scss" scoped>
.rewards-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.rewards-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.rewards-panel {
position: relative;
width: 90%;
max-height: 80vh;
background: $bg-card;
border-radius: $radius-xl;
overflow: hidden;
box-shadow: $shadow-lg;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(50rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.rewards-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-lg;
border-bottom: 1rpx solid $border-color-light;
}
.rewards-title {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
}
.rewards-close {
font-size: 48rpx;
color: $text-sub;
line-height: 1;
padding: $spacing-xs;
}
.rewards-list {
max-height: 60vh;
padding: $spacing-lg;
}
.rewards-group-v2 {
margin-bottom: $spacing-lg;
}
.group-header-row {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-bottom: $spacing-sm;
}
.group-badge {
font-size: $font-xs;
font-weight: 700;
color: $brand-primary;
background: rgba($brand-primary, 0.1);
padding: 4rpx $spacing-sm;
border-radius: $radius-sm;
&.badge-boss {
background: $gradient-gold;
color: #6b4b1f;
}
}
.group-total-prob {
font-size: $font-xs;
color: $text-sub;
}
.rewards-item {
display: flex;
align-items: center;
padding: $spacing-sm 0;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
&:last-child {
border-bottom: none;
}
}
.rewards-thumb {
width: 100rpx;
height: 100rpx;
border-radius: $radius-md;
margin-right: $spacing-md;
background: $bg-secondary;
flex-shrink: 0;
}
.rewards-info {
flex: 1;
min-width: 0;
}
.rewards-name-row {
display: flex;
align-items: center;
gap: $spacing-xs;
margin-bottom: $spacing-xs;
}
.rewards-name {
font-size: $font-md;
font-weight: 600;
color: $text-main;
@include text-ellipsis(1);
}
.rewards-tag {
font-size: $font-xxs;
font-weight: 700;
color: #6b4b1f;
background: $gradient-gold;
padding: 2rpx 8rpx;
border-radius: $radius-sm;
flex-shrink: 0;
}
.rewards-percent {
font-size: $font-sm;
color: $text-sub;
}
.rewards-empty {
text-align: center;
color: $text-sub;
padding: $spacing-xl;
font-size: $font-sm;
}
</style>

View File

@ -0,0 +1,248 @@
<template>
<view>
<view class="section-header">
<text class="section-title">{{ title }}</text>
<text class="section-more" @tap="$emit('view-all')">查看全部</text>
</view>
<!-- 分组展示 -->
<view v-if="grouped && rewardGroups.length > 0">
<view class="prize-level-row" v-for="group in rewardGroups" :key="group.level">
<view class="level-header-row">
<view class="level-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">
{{ isMatchingGroup(group.level) ? group.level : `${group.level}` }}
</view>
<text class="level-prob">总概率 {{ group.totalPercent }}%</text>
</view>
<scroll-view class="preview-scroll" scroll-x>
<view class="preview-item" v-for="(item, idx) in group.rewards" :key="item.id || idx">
<view class="prize-tag tag-boss" v-if="item.boss">BOSS</view>
<image class="preview-img" :src="item.image" mode="aspectFill" />
<view class="preview-name">{{ item.title }}</view>
</view>
</scroll-view>
</view>
</view>
<!-- 简单列表展示 -->
<view v-else-if="rewards.length > 0">
<scroll-view class="preview-scroll" scroll-x>
<view class="preview-item" v-for="(item, idx) in rewards" :key="idx">
<view class="prize-tag" :class="{ 'tag-boss': item.boss }">{{ item.boss ? 'BOSS' : (item.level || '赏') }}</view>
<image class="preview-img" :src="item.image" mode="aspectFill" />
<view class="preview-name">{{ item.title }}</view>
</view>
</scroll-view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state">
<text class="empty-icon">📭</text>
<text class="empty-text">{{ emptyText }}</text>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { groupRewardsByLevel } from '@/utils/activity'
const props = defineProps({
title: {
type: String,
default: '奖池配置'
},
rewards: {
type: Array,
default: () => []
},
grouped: {
type: Boolean,
default: false
},
playType: {
type: String,
default: 'normal'
},
emptyText: {
type: String,
default: '暂无奖品配置'
}
})
defineEmits(['view-all'])
// ""
const isMatchingGroup = (level) => {
return String(level || '').includes('对子')
}
const rewardGroups = computed(() => {
if (!props.grouped) return []
return groupRewardsByLevel(props.rewards, props.playType)
})
</script>
<style lang="scss" scoped>
/* ============================================
奖池预览 - 与原始设计完全一致
============================================ */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
}
.section-title {
font-size: $font-md;
font-weight: 700;
color: $text-main;
}
.section-more {
font-size: $font-sm;
color: $text-tertiary;
display: flex;
align-items: center;
&::after {
content: '>';
font-family: monospace;
margin-left: 6rpx;
font-weight: 700;
}
}
/* 等级分组 */
.prize-level-row {
margin-bottom: $spacing-lg;
background: rgba(0,0,0,0.02);
padding: $spacing-md;
border-radius: $radius-lg;
&:last-child {
margin-bottom: 0;
}
}
.level-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
}
.level-badge {
display: inline-block;
font-size: $font-xs;
font-weight: 900;
color: $text-main;
background: #F0F0F0;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-style: italic;
border: 1rpx solid rgba(0,0,0,0.05);
box-shadow: $shadow-xs;
&.badge-boss {
background: $gradient-gold;
color: #78350F;
border-color: rgba(217, 119, 6, 0.3);
}
}
.level-prob {
font-size: 22rpx;
color: $brand-primary;
font-weight: 800;
opacity: 0.9;
}
/* 预览滚动区域 */
.preview-scroll {
white-space: nowrap;
width: 100%;
}
.preview-item {
display: inline-block;
width: 180rpx;
margin-right: $spacing-md;
vertical-align: top;
position: relative;
transition: transform 0.2s;
&:active {
transform: scale(0.96);
}
&:last-child {
margin-right: 0;
}
}
.preview-img {
width: 180rpx;
height: 180rpx;
border-radius: $radius-lg;
background: $bg-secondary;
margin-bottom: $spacing-sm;
box-shadow: $shadow-sm;
border: 1rpx solid rgba(0,0,0,0.03);
}
.preview-name {
font-size: $font-xs;
color: $text-secondary;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font-weight: 500;
}
/* 奖品标签 */
.prize-tag {
position: absolute;
top: 10rpx;
left: 10rpx;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: $font-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-sm;
z-index: 10;
font-weight: 700;
backdrop-filter: blur(4rpx);
transform: scale(0.9);
transform-origin: top left;
&.tag-boss {
background: $gradient-brand;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.4);
}
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl;
color: $text-sub;
min-height: 300rpx; /* 防止切换时布局跳动 */
}
.empty-icon {
font-size: 64rpx;
margin-bottom: $spacing-sm;
}
.empty-text {
font-size: $font-sm;
}
</style>

View File

@ -0,0 +1,125 @@
<template>
<view v-if="visible" class="rules-overlay" @touchmove.stop.prevent>
<view class="rules-mask" @tap="close"></view>
<view class="rules-panel" @tap.stop>
<view class="rules-header">
<text class="rules-title">{{ title }}</text>
<text class="rules-close" @tap="close">×</text>
</view>
<scroll-view scroll-y class="rules-content">
<!-- 使用 rich-text 渲染富文本 HTML -->
<rich-text v-if="content" class="rules-richtext" :nodes="content"></rich-text>
<view v-else class="rules-empty">暂无活动规则</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '活动规则'
},
content: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:visible'])
function close() {
emit('update:visible', false)
}
</script>
<style lang="scss" scoped>
.rules-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9000;
}
.rules-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10rpx);
}
.rules-panel {
position: absolute;
left: $spacing-lg;
right: $spacing-lg;
bottom: calc(env(safe-area-inset-bottom) + 24rpx);
max-height: 70vh;
background: rgba($bg-card, 0.95);
border-radius: $radius-xl;
box-shadow: $shadow-card;
border: 1rpx solid rgba(255, 255, 255, 0.5);
overflow: hidden;
animation: slideUp 0.25s ease-out;
}
.rules-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-lg;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
}
.rules-title {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
}
.rules-close {
font-size: 48rpx;
line-height: 1;
color: $text-tertiary;
padding: 0 10rpx;
}
.rules-content {
max-height: 55vh;
padding: $spacing-lg;
}
.rules-richtext {
font-size: $font-sm;
color: $text-main;
line-height: 1.8;
}
.rules-empty {
text-align: center;
color: $text-sub;
padding: $spacing-xl;
font-size: $font-sm;
}
@keyframes slideUp {
from {
transform: translateY(40rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,10 @@
/**
* Activity 组件统一导出
*/
export { default as ActivityPageLayout } from './ActivityPageLayout.vue'
export { default as ActivityHeader } from './ActivityHeader.vue'
export { default as ActivityTabs } from './ActivityTabs.vue'
export { default as RewardsPreview } from './RewardsPreview.vue'
export { default as RewardsPopup } from './RewardsPopup.vue'
export { default as RecordsList } from './RecordsList.vue'

View File

@ -0,0 +1,87 @@
<template>
<view class="app-tab-bar-toutiao">
<view class="tab-bar-item" @tap="switchTab('pages/cabinet/index')">
<image class="tab-icon" :src="selected === 0 ? '/static/tab/box_active.png' : '/static/tab/box.png'" mode="aspectFit"></image>
<text class="tab-text" :class="{ active: selected === 0 }">盒柜</text>
</view>
<view class="tab-bar-item" @tap="switchTab('pages/mine/index')">
<image class="tab-icon" :src="selected === 1 ? '/static/tab/profile_active.png' : '/static/tab/profile.png'" mode="aspectFit"></image>
<text class="tab-text" :class="{ active: selected === 1 }">我的</text>
</view>
</view>
</template>
<script>
export default {
data() {
return {
selected: 0 // ""
}
},
mounted() {
this.updateSelected()
},
onShow() {
this.updateSelected()
},
methods: {
updateSelected() {
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
const route = currentPage.route
if (route === 'pages/cabinet/index') this.selected = 0
else if (route === 'pages/mine/index') this.selected = 1
}
},
switchTab(url) {
uni.switchTab({
url: '/' + url
})
}
}
}
</script>
<style lang="scss" scoped>
.app-tab-bar-toutiao {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background: #FFFFFF;
border-top: 1rpx solid #E5E5E5;
display: flex;
justify-content: space-around;
align-items: center;
padding-bottom: env(safe-area-inset-bottom);
z-index: 999;
}
.tab-bar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.tab-icon {
width: 48rpx;
height: 48rpx;
margin-bottom: 4rpx;
}
.tab-text {
font-size: 22rpx;
color: #7A7E83;
&.active {
color: #007AFF;
}
}
</style>

105
components/app-tab-bar.vue Normal file
View File

@ -0,0 +1,105 @@
<template>
<!-- #ifndef MP-TOUTIAO -->
<view class="app-tab-bar">
<view class="tab-bar-item" @tap="switchTab('pages/index/index')">
<image class="tab-icon" :src="selected === 0 ? '/static/tab/home_active.png' : '/static/tab/home.png'" mode="aspectFit"></image>
<text class="tab-text" :class="{ active: selected === 0 }">首页</text>
</view>
<view class="tab-bar-item" @tap="switchTab('pages/shop/index')">
<image class="tab-icon" :src="selected === 1 ? '/static/tab/shop_active.png' : '/static/tab/shop.png'" mode="aspectFit"></image>
<text class="tab-text" :class="{ active: selected === 1 }">商城</text>
</view>
<view class="tab-bar-item" @tap="switchTab('pages/cabinet/index')">
<image class="tab-icon" :src="selected === 2 ? '/static/tab/box_active.png' : '/static/tab/box.png'" mode="aspectFit"></image>
<text class="tab-text" :class="{ active: selected === 2 }">盒柜</text>
</view>
<view class="tab-bar-item" @tap="switchTab('pages/mine/index')">
<image class="tab-icon" :src="selected === 3 ? '/static/tab/profile_active.png' : '/static/tab/profile.png'" mode="aspectFit"></image>
<text class="tab-text" :class="{ active: selected === 3 }">我的</text>
</view>
</view>
<!-- #endif -->
</template>
<script>
export default {
// #ifndef MP-TOUTIAO
data() {
return {
selected: 0 // ""
}
},
mounted() {
this.updateSelected()
},
onShow() {
this.updateSelected()
},
methods: {
updateSelected() {
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
const route = currentPage.route
if (route === 'pages/index/index') this.selected = 0
else if (route === 'pages/shop/index') this.selected = 1
else if (route === 'pages/cabinet/index') this.selected = 2
else if (route === 'pages/mine/index') this.selected = 3
}
},
switchTab(url) {
uni.switchTab({
url: '/' + url
})
}
}
// #endif
}
</script>
<style lang="scss" scoped>
/* #ifndef MP-TOUTIAO */
.app-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background: #FFFFFF;
border-top: 1rpx solid #E5E5E5;
display: flex;
justify-content: space-around;
align-items: center;
padding-bottom: env(safe-area-inset-bottom);
z-index: 999;
}
.tab-bar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.tab-icon {
width: 48rpx;
height: 48rpx;
margin-bottom: 4rpx;
}
.tab-text {
font-size: 22rpx;
color: #7A7E83;
&.active {
color: #007AFF;
}
}
/* #endif */
</style>

View File

@ -0,0 +1,208 @@
# bindbox-mini 代码冗余分析
## 项目概述
bindbox-mini 是一个基于 uni-app 的微信小程序项目,主要实现盲盒/抽赏类活动功能。
### 技术栈
- 框架uni-app (Vue 3 Composition API)
- 样式SCSS
- 状态管理Vue ref/computed
### 核心页面
| 页面 | 路径 | 行数 | 功能描述 |
|------|------|------|----------|
| 一番赏 | `pages/activity/yifanshang/index.vue` | 1229 | 格位选择抽奖 |
| 对对碰 | `pages/activity/duiduipeng/index.vue` | 2291 | 配对游戏 |
| 无限赏 | `pages/activity/wuxianshang/index.vue` | 1559 | 多次抽奖 |
| 扭蛋(啪嗒) | `pages/activity/pata/index.vue` | 399 | 入口页面 |
---
## 🔴 已识别的冗余问题
### 1. 模板结构重复
三个主要活动页面yifanshang/duiduipeng/wuxianshang共享**几乎相同的页面布局结构**
```vue
<!-- 重复出现在每个页面 -->
<view class="page-wrapper">
<view class="bg-decoration">
<view class="orb orb-1"></view>
<view class="orb orb-2"></view>
</view>
<view class="page-bg">
<image class="bg-image" :src="coverUrl" mode="aspectFill" />
<view class="bg-mask"></view>
</view>
<scroll-view class="main-scroll" scroll-y>
<view class="header-card animate-enter"><!-- 相同的 header-card 结构 --></view>
<view class="section-container"><!-- tabs/pool/records --></view>
</scroll-view>
</view>
```
**冗余程度**约100-150行相似模板代码 × 3个页面 = ~400行冗余
---
### 2. 工具函数重复
以下函数在多个页面中**完全重复定义**
| 函数名 | 出现位置 | 功能 |
|--------|----------|------|
| `cleanUrl(u)` | yifanshang, duiduipeng, wuxianshang | 清理URL字符串 |
| `truthy(v)` | yifanshang, duiduipeng, wuxianshang | 判断真值 |
| `detectBoss(i)` | yifanshang, duiduipeng, wuxianshang | 检测BOSS奖 |
| `unwrap(list)` | yifanshang, duiduipeng, wuxianshang | 解包API返回 |
| `normalizeIssues(list)` | yifanshang, duiduipeng, wuxianshang | 标准化期数据 |
| `normalizeRewards(list)` | yifanshang, duiduipeng, wuxianshang | 标准化奖励数据 |
| `statusToText(s)` | yifanshang, duiduipeng, wuxianshang | 状态转文本 |
| `formatPercent(v)` | yifanshang, duiduipeng, wuxianshang | 格式化百分比 |
| `levelToAlpha(level)` | duiduipeng, wuxianshang | 等级数字转字母 |
| `isFresh(ts)` | yifanshang, duiduipeng, wuxianshang | 判断缓存新鲜度 |
| `getRewardCache()` | yifanshang, duiduipeng, wuxianshang | 获取奖励缓存 |
| `pickLatestIssueId(list)` | yifanshang, duiduipeng, wuxianshang | 查找最新期ID |
| `setSelectedById(id)` | yifanshang, duiduipeng, wuxianshang | 设置选中期 |
| `prevIssue()` / `nextIssue()` | yifanshang, duiduipeng, wuxianshang | 期数切换 |
**冗余程度**约200-300行工具函数 × 3个页面 = ~700行冗余
---
### 3. API调用逻辑重复
以下API调用模式在多个页面中重复
```javascript
// fetchDetail - 获取活动详情3处重复
async function fetchDetail(id) {
const data = await getActivityDetail(id)
detail.value = data || {}
statusText.value = statusToText(detail.value.status)
// ...
}
// fetchIssues - 获取期列表3处重复
async function fetchIssues(id) {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
// ...
}
// fetchRewardsForIssues - 获取奖励3处重复
async function fetchRewardsForIssues(activityId) {
// ~50行相似代码
}
// fetchWinRecords - 获取购买记录3处重复
async function fetchWinRecords(actId, issId) {
// ~30行相似代码
}
```
**冗余程度**约150-200行API调用代码 × 3个页面 = ~500行冗余
---
### 4. 样式代码重复
以下SCSS样式在三个页面中几乎**完全相同**
```scss
// 基础布局(~80行
.page-wrapper, .bg-decoration, .orb, @keyframes float
.page-bg, .bg-image, .bg-mask, .main-scroll
// 头部卡片(~100行
.header-card, .header-cover, .header-info, .header-title
.header-price-row, .price-symbol, .price-num, .price-unit
.header-tags, .tag-item, .header-actions, .action-btn, .action-icon
// 板块容器(~50行
.section-container, .section-header, .section-title, .section-more
// Tabs切换~50行
.modern-tabs, .tab-item, .active-dot
// 奖池预览(~80行
.preview-scroll, .preview-item, .preview-img, .preview-name, .prize-tag
// 购买记录(~60行
.records-list, .record-item, .record-img, .record-info
// 弹窗样式(~100行
.rewards-overlay, .rewards-mask, .rewards-panel, .rewards-header, .rewards-list
```
**冗余程度**约500-600行样式代码 × 3个页面 = ~1500行冗余
---
### 5. 状态管理重复
以下响应式状态在多个页面中重复定义:
```javascript
// 每个页面都有类似的状态定义
const detail = ref({})
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const activityId = ref('')
const tabActive = ref('pool')
const winRecords = ref([])
const rewardsVisible = ref(false)
// ...
```
---
## 📊 冗余统计汇总
| 类别 | 估算冗余行数 | 占比 |
|------|-------------|------|
| 模板结构 | ~400行 | 13% |
| 工具函数 | ~700行 | 22% |
| API调用逻辑 | ~500行 | 16% |
| SCSS样式 | ~1500行 | 48% |
| **合计** | **~3100行** | **100%** |
当前三个主要活动页面总计约 **5079行**1229+2291+1559冗余代码约占 **61%**
---
## ❓ 需要确认的问题
1. **重构方向**:是希望进行完整的组件化重构,还是仅提取共用工具函数?
2. **优先级**
- 先处理工具函数冗余?(影响最小,风险最低)
- 先处理模板/组件冗余?(收益最大,但改动较大)
- 先处理样式冗余?(提取公共样式文件)
3. **兼容性考虑**:是否需要保留现有的页面独立性(便于后续定制化)?
4. **测试策略**:目前项目有自动化测试吗?重构后如何验证功能正确性?
---
## 🎯 初步建议
### 方案A渐进式重构推荐
1. **第一步**:提取共用工具函数到 `utils/activity.js`
2. **第二步**:提取共用样式到 `styles/activity-common.scss`
3. **第三步**创建共用组件ActivityHeader, ActivityTabs, RewardsPopup
4. **第四步**:重构各活动页面使用共用组件
### 方案B完全组件化
创建通用活动页面框架 `ActivityPageLayout.vue`,各玩法页面只需实现差异化部分。
---
*文档创建时间2025-12-25*

View File

@ -0,0 +1,323 @@
# bindbox-mini 组件化重构设计
## 架构目标
将三个活动页面yifanshang/duiduipeng/wuxianshang共约5079行代码减少至约2500行消除61%的冗余。
---
## 架构设计图
```mermaid
graph TB
subgraph Utils[工具层 utils/]
A1[activity.js<br>活动相关工具函数]
A2[format.js<br>格式化工具]
A3[cache.js<br>缓存管理]
end
subgraph Composables[组合式函数 composables/]
B1[useActivity.js<br>活动数据管理]
B2[useIssues.js<br>期数据管理]
B3[useRewards.js<br>奖励数据管理]
B4[usePayment.js<br>支付流程]
end
subgraph Components[组件层 components/]
subgraph Layout[布局组件]
C1[ActivityPageLayout.vue<br>活动页面框架]
C2[ActivityHeader.vue<br>头部卡片]
end
subgraph Biz[业务组件]
C3[ActivityTabs.vue<br>Tab切换]
C4[RewardsPopup.vue<br>奖品弹窗]
C5[RecordsList.vue<br>购买记录]
C6[RewardsPreview.vue<br>奖池预览]
end
subgraph Existing[已有组件]
C7[PaymentPopup.vue]
C8[FlipGrid.vue]
end
end
subgraph Pages[页面层 pages/activity/]
D1[yifanshang - 选号+专属业务]
D2[duiduipeng - 对对碰游戏+专属业务]
D3[wuxianshang - 多档抽奖+专属业务]
end
Utils --> Composables
Composables --> Pages
Components --> Pages
```
---
## 详细模块设计
### 1. 工具函数层 `utils/`
#### `utils/activity.js` - 活动相关工具 [NEW]
```javascript
// 数据标准化
export function unwrap(list) { /* ... */ }
export function normalizeIssues(list) { /* ... */ }
export function normalizeRewards(list) { /* ... */ }
// 值判断
export function truthy(v) { /* ... */ }
export function detectBoss(i) { /* ... */ }
export function levelToAlpha(level) { /* ... */ }
// 状态转换
export function statusToText(s) { /* ... */ }
```
#### `utils/format.js` - 格式化工具 [NEW]
```javascript
export function cleanUrl(u) { /* ... */ }
export function formatPercent(v) { /* ... */ }
export function formatDateTime(v) { /* ... */ }
export function formatPrice(cents) { /* ... */ }
```
#### `utils/cache.js` - 缓存管理 [NEW]
```javascript
export function isFresh(ts, ttl = 24 * 60 * 60 * 1000) { /* ... */ }
export function getRewardCache() { /* ... */ }
export function setRewardCache(activityId, issueId, value) { /* ... */ }
```
---
### 2. 组合式函数层 `composables/`
#### `composables/useActivity.js` [NEW]
```javascript
export function useActivity(activityId) {
const detail = ref({})
const coverUrl = computed(() => cleanUrl(detail.value?.image || detail.value?.banner || ''))
const statusText = computed(() => statusToText(detail.value?.status))
const pricePerDraw = computed(() => (Number(detail.value?.price_draw || 0) / 100))
async function fetchDetail() { /* ... */ }
return { detail, coverUrl, statusText, pricePerDraw, fetchDetail }
}
```
#### `composables/useIssues.js` [NEW]
```javascript
export function useIssues(activityId) {
const issues = ref([])
const selectedIssueIndex = ref(0)
const currentIssueId = computed(() => issues.value[selectedIssueIndex.value]?.id || '')
const currentIssueTitle = computed(() => /* ... */)
async function fetchIssues() { /* ... */ }
function prevIssue() { /* ... */ }
function nextIssue() { /* ... */ }
function setSelectedById(id) { /* ... */ }
return { issues, selectedIssueIndex, currentIssueId, currentIssueTitle, fetchIssues, prevIssue, nextIssue, setSelectedById }
}
```
#### `composables/useRewards.js` [NEW]
```javascript
export function useRewards(activityId, currentIssueId) {
const rewardsMap = ref({})
const currentIssueRewards = computed(() => rewardsMap.value[currentIssueId.value] || [])
const rewardGroups = computed(() => /* 按level分组 */)
async function fetchRewardsForIssues(issueList) { /* 带缓存 */ }
return { rewardsMap, currentIssueRewards, rewardGroups, fetchRewardsForIssues }
}
```
#### `composables/useRecords.js` [NEW]
```javascript
export function useRecords() {
const winRecords = ref([])
async function fetchWinRecords(activityId, issueId) { /* ... */ }
return { winRecords, fetchWinRecords }
}
```
---
### 3. 组件层 `components/`
#### `ActivityPageLayout.vue` [NEW] - 页面框架组件
Props:
- `coverUrl: String` - 背景图URL
Slots:
- `header` - 头部卡片区域
- `content` - 主要内容tabs等
- `footer` - 底部操作栏
- `modals` - 弹窗区域
#### `ActivityHeader.vue` [NEW] - 头部卡片
Props:
- `title: String`
- `price: Number` (分)
- `priceUnit: String` - 价格单位(如"/发"、"/次"
- `coverUrl: String`
- `tags: Array<String>`
- `scheduledTime: String` (可选)
Events:
- `@show-rules`
- `@go-cabinet`
#### `ActivityTabs.vue` [NEW] - Tab切换
Props:
- `modelValue: String` - 当前tab ('pool' | 'records')
- `tabs: Array<{key, label}>`
Events:
- `@update:modelValue`
#### `RewardsPreview.vue` [NEW] - 奖池预览
Props:
- `rewards: Array`
- `grouped: Boolean` - 是否按等级分组显示
#### `RewardsPopup.vue` [NEW] - 奖品弹窗
Props:
- `visible: Boolean`
- `title: String`
- `rewardGroups: Array` - 按等级分组的奖励
Events:
- `@update:visible`
#### `RecordsList.vue` [NEW] - 购买记录列表
Props:
- `records: Array`
- `emptyText: String`
---
### 4. 样式层 `styles/`
#### `styles/activity-common.scss` [NEW]
提取共用样式约600行
- 页面布局:`.page-wrapper`, `.bg-decoration`, `.orb`, `@keyframes float`
- 背景处理:`.page-bg`, `.bg-image`, `.bg-mask`
- 入场动画:`.animate-enter`, `.stagger-*`
- 头部卡片样式可在ActivityHeader组件内联
- 板块容器:`.section-container`, `.section-header`
- Tabs样式可在ActivityTabs组件内联
- 预览列表:`.preview-scroll`, `.preview-item`
- 记录列表:`.records-list`, `.record-item`
- 弹窗样式可在RewardsPopup组件内联
---
## 重构后页面结构示例
### yifanshang/index.vue (预计约400行→优化后)
```vue
<template>
<ActivityPageLayout :cover-url="coverUrl">
<template #header>
<ActivityHeader
:title="detail.name"
:price="detail.price_draw"
price-unit="/发"
:cover-url="coverUrl"
:tags="['公开透明', '拒绝套路']"
:scheduled-time="scheduledTimeText"
@show-rules="showRules"
@go-cabinet="goCabinet"
/>
</template>
<template #content>
<ActivityTabs v-model="tabActive">
<template #pool>
<RewardsPreview :rewards="currentIssueRewards" @view-all="openRewardsPopup" />
</template>
<template #records>
<RecordsList :records="winRecords" />
</template>
</ActivityTabs>
<!-- 一番赏专属:选号组件 -->
<YifanSelector ... />
</template>
<template #modals>
<RewardsPopup v-model:visible="rewardsVisible" ... />
<FlipGrid ref="flipRef" ... />
</template>
</ActivityPageLayout>
</template>
<script setup>
import { useActivity, useIssues, useRewards, useRecords } from '@/composables'
// 专注于一番赏特有的业务逻辑
</script>
```
---
## 文件变更清单
### 新增文件
| 文件路径 | 行数估算 | 说明 |
|----------|---------|------|
| `utils/activity.js` | ~80 | 活动工具函数 |
| `utils/format.js` | ~50 | 格式化工具 |
| `utils/cache.js` | ~40 | 缓存管理 |
| `composables/useActivity.js` | ~50 | 活动数据composable |
| `composables/useIssues.js` | ~80 | 期数据composable |
| `composables/useRewards.js` | ~80 | 奖励数据composable |
| `composables/useRecords.js` | ~40 | 记录composable |
| `components/ActivityPageLayout.vue` | ~150 | 页面框架 |
| `components/ActivityHeader.vue` | ~200 | 头部卡片 |
| `components/ActivityTabs.vue` | ~100 | Tab切换 |
| `components/RewardsPreview.vue` | ~120 | 奖池预览 |
| `components/RewardsPopup.vue` | ~150 | 奖品弹窗 |
| `components/RecordsList.vue` | ~80 | 记录列表 |
| **小计** | **~1220** | |
### 修改文件
| 文件路径 | 原行数 | 预计行数 | 变化 |
|----------|-------|---------|------|
| `yifanshang/index.vue` | 1229 | ~400 | -829 |
| `duiduipeng/index.vue` | 2291 | ~800 | -1491 |
| `wuxianshang/index.vue` | 1559 | ~500 | -1059 |
| **小计** | **5079** | **~1700** | **-3379** |
### 净变化
- 新增:~1220行
- 删除:~3379行
- **净减少:~2159行42%**
---
*设计文档创建时间2025-12-25*

View File

@ -1,6 +1,6 @@
{
"name" : "app_client",
"appid" : "",
"appid" : "__UNI__07C684D",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
@ -57,7 +57,11 @@
"es6" : true,
"postcss" : true
},
"usingComponents" : true
"usingComponents" : true,
"lazyCodeLoading" : "requiredComponents",
"optimization" : {
"subPackages" : true
}
},
"mp-alipay" : {
"usingComponents" : true
@ -67,7 +71,12 @@
},
"mp-toutiao" : {
"usingComponents" : true,
"appid" : "ttf031868c6f33d91001"
"appid" : "ttf031868c6f33d91001",
"privacy" : {
"getPhoneNumber" : {
"desc" : "用于登录和账号绑定"
}
}
},
"uniStatistics" : {
"enable" : false

File diff suppressed because it is too large Load Diff

View File

@ -94,10 +94,10 @@ function onActivityTap(a) {
let path = ''
// Navigate to DETAIL, not list
if (name.includes('一番赏')) path = '/pages/activity/yifanshang/index'
else if (name.includes('无限赏')) path = '/pages/activity/wuxianshang/index'
else if (name.includes('对对碰')) path = '/pages/activity/duiduipeng/index'
else if (name.includes('爬塔')) path = '/pages/activity/pata/index'
if (name.includes('一番赏')) path = '/pages-activity/activity/yifanshang/index'
else if (name.includes('无限赏')) path = '/pages-activity/activity/wuxianshang/index'
else if (name.includes('对对碰')) path = '/pages-activity/activity/duiduipeng/index'
else if (name.includes('爬塔')) path = '/pages-activity/activity/pata/index'
if (path && id) {
uni.navigateTo({ url: `${path}?id=${id}` })
@ -122,17 +122,27 @@ import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
onShareAppMessage(() => {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
// #ifdef MP-TOUTIAO
//
return {
title: `${title.value || '精彩活动'} - 奇盒潮玩`,
title: `${title.value || '精彩活动'} - 柯大鸭潮玩`,
path: `/pages/shop/index?invite_code=${inviteCode}`,
imageUrl: '/static/logo.png'
}
// #endif
// #ifndef MP-TOUTIAO
return {
title: `${title.value || '精彩活动'} - 柯大鸭潮玩`,
path: `/pages/index/index?invite_code=${inviteCode}`,
imageUrl: '/static/logo.png'
}
// #endif
})
onShareTimeline(() => {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
return {
title: `${title.value || '精彩活动'} - 奇盒潮玩`,
title: `${title.value || '精彩活动'} - 柯大鸭潮玩`,
query: `invite_code=${inviteCode}`,
imageUrl: '/static/logo.png'
}

View File

@ -0,0 +1,398 @@
<template>
<view class="page-wrapper">
<!-- Rebuild Trigger -->
<!-- 背景层 -->
<image class="bg-fixed" :src="detail.banner || ''" mode="aspectFill" />
<view class="bg-mask"></view>
<view class="content-area">
<!-- 顶部信息 -->
<view class="header-section">
<view class="title-box">
<text class="main-title">扫雷挑战</text>
<text class="sub-title">福利放送 智勇通关</text>
</view>
<view class="rule-btn" @tap="showRules">规则</view>
</view>
<!-- 挑战区域 (模拟塔层) -->
<view class="tower-container">
<view class="tower-level current">
<view class="level-info">
<text class="level-num">当前挑战</text>
<text class="level-name">扫雷福利局</text>
</view>
<view class="level-status">进行中</view>
</view>
<!-- 剩余次数展示 -->
<view class="ticket-info">
<text class="ticket-label">剩余挑战次数</text>
<text class="ticket-count">{{ remainingTimes }}</text>
</view>
</view>
<!-- 操作区 -->
<view class="action-area">
<button class="challenge-btn" :disabled="!canPlay" :class="{ disabled: !canPlay }" @tap="onStartChallenge">
{{ canPlay ? '开始挑战' : '去获取资格' }}
</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getActivityDetail } from '../../../api/appUser'
const activityId = ref('')
const detail = ref({})
const remainingTimes = ref(0) //
const ticketId = ref('') // ID
const canPlay = computed(() => remainingTimes.value > 0)
async function loadData(id) {
try {
const d = await getActivityDetail(id)
detail.value = d || {}
} catch (e) {
console.error(e)
}
}
//
async function checkEligibility() {
// TODO: Replace with actual API call to check bonus/ticket status
// e.g. const res = await getMinesweeperEligibility()
// 1
setTimeout(() => {
remainingTimes.value = 1
ticketId.value = 'mock-ticket-123456'
}, 500)
}
function onStartChallenge() {
if (!canPlay.value) {
uni.showToast({ title: '去玩其他游戏赢取资格吧!', icon: 'none' })
// TODO: Navigate to other games or shop
return
}
const token = uni.getStorageSync('token')
if (!token) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
// Navigate to WebView Game
// TODO: Replace with real game URL
const gameUrl = 'http://localhost:5174/'
uni.navigateTo({
url: `/pages-game/game/webview?url=${encodeURIComponent(gameUrl)}&ticket=${ticketId.value}`
})
}
function showRules() {
uni.showModal({
title: '规则',
content: '1. 参与平台其他游戏有机会获得扫雷挑战资格。\n2. 挑战成功可获得丰厚奖励。\n3. 扫雷过程中请保持网络通畅。',
showCancel: false
})
}
onLoad((opts) => {
if (opts.id) {
activityId.value = opts.id
loadData(opts.id)
}
})
onShow(() => {
checkEligibility()
})
</script>
<style lang="scss" scoped>
/* ============================================
爬塔页面 - 沉浸式暗黑风格 (SCSS Integration)
============================================ */
$local-gold: #FFD700; //
.page-wrapper {
min-height: 100vh;
position: relative;
background: $bg-dark;
color: $text-dark-main;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 背景装饰 - 暗黑版 */
.bg-decoration {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
&::before {
content: '';
position: absolute;
top: -10%; left: -20%;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.1) 0%, transparent 70%);
filter: blur(80rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
bottom: 10%; right: -10%;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($local-gold, 0.08) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.5;
animation: float 12s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20rpx, 30rpx); }
}
.bg-fixed {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
opacity: 0.3;
z-index: 0;
filter: blur(8rpx);
}
.bg-mask {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(180deg, rgba($bg-dark, 0.85), $bg-dark 95%);
z-index: 1;
}
.content-area {
position: relative;
z-index: 2;
flex: 1;
display: flex;
flex-direction: column;
padding: $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
}
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-xl;
animation: fadeInDown 0.6s ease-out;
}
.title-box {
display: flex;
flex-direction: column;
}
.main-title {
font-size: 60rpx;
font-weight: 900;
font-style: italic;
display: block;
text-shadow: 0 4rpx 16rpx rgba(0,0,0,0.6);
background: linear-gradient(180deg, #fff, #b3b3b3);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: 2rpx;
}
.sub-title {
font-size: 26rpx;
opacity: 0.8;
margin-top: $spacing-xs;
display: block;
letter-spacing: 4rpx;
color: $brand-primary;
text-transform: uppercase;
}
.rule-btn {
background: rgba(255,255,255,0.1);
border: 1px solid $border-dark;
padding: 12rpx 32rpx;
border-radius: 100rpx;
font-size: 24rpx;
backdrop-filter: blur(10rpx);
transition: all 0.2s;
color: rgba(255,255,255,0.9);
&:active {
background: rgba(255,255,255,0.25);
transform: scale(0.96);
}
}
.tower-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding-bottom: 40rpx;
}
.tower-level {
width: 100%;
background: $bg-dark-card;
backdrop-filter: blur(20rpx);
padding: 48rpx;
border-radius: $radius-xl;
box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.3);
margin-bottom: 40rpx;
border: 1px solid $border-dark;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
overflow: hidden;
animation: zoomIn 0.5s ease-out backwards;
&::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
}
&.current {
background: rgba($local-gold, 0.15);
border-color: rgba($local-gold, 0.5);
box-shadow: 0 0 40rpx rgba($local-gold, 0.15), inset 0 0 20rpx rgba($local-gold, 0.05);
}
}
.level-info { display: flex; flex-direction: column; z-index: 1; }
.level-num {
font-size: 24rpx;
color: $text-dark-sub;
margin-bottom: 8rpx;
text-transform: uppercase;
letter-spacing: 2rpx;
}
.level-name {
font-size: 48rpx;
font-weight: 700;
color: $text-dark-main;
text-shadow: 0 4rpx 8rpx rgba(0,0,0,0.3);
}
.level-status {
font-size: 24rpx;
background: linear-gradient(135deg, $local-gold, $brand-secondary);
color: #3e2723;
padding: 8rpx 20rpx;
border-radius: 12rpx;
font-weight: 800;
box-shadow: 0 4rpx 16rpx rgba($brand-secondary, 0.3);
z-index: 1;
}
.ticket-info {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 40rpx;
animation: fadeInUp 0.5s ease-out backwards;
animation-delay: 0.2s;
}
.ticket-label {
font-size: 28rpx;
color: $text-dark-sub;
margin-bottom: 10rpx;
}
.ticket-count {
font-size: 80rpx;
font-weight: 900;
color: $local-gold;
font-family: 'DIN Alternate', sans-serif;
text-shadow: 0 0 20rpx rgba($local-gold, 0.4);
}
.action-area {
position: fixed;
left: 40rpx;
right: 40rpx;
bottom: calc(40rpx + env(safe-area-inset-bottom));
background: rgba(26, 26, 26, 0.85);
backdrop-filter: blur(30rpx);
padding: 24rpx 40rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4);
z-index: 100;
animation: slideUp 0.6s cubic-bezier(0.23, 1, 0.32, 1) backwards;
}
.challenge-btn {
background: $gradient-brand !important;
color: #fff !important;
font-weight: 900;
border-radius: 999rpx;
padding: 0 60rpx;
height: 88rpx;
line-height: 88rpx;
font-size: 32rpx;
box-shadow: 0 12rpx 32rpx rgba(255, 107, 0, 0.3);
border: none;
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
width: 100%;
&::before {
content: '';
position: absolute;
top: -50%;
left: -150%;
width: 200%;
height: 200%;
background: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0) 70%
);
transform: rotate(25deg);
animation: btnShine 4s infinite cubic-bezier(0.19, 1, 0.22, 1);
pointer-events: none;
}
&:active {
transform: scale(0.94);
}
&.disabled {
background: #333 !important;
color: #666 !important;
box-shadow: none;
&::before { display: none; }
}
}
@keyframes btnShine {
0% { left: -150%; }
100% { left: 150%; }
}
</style>

View File

@ -0,0 +1,688 @@
<template>
<ActivityPageLayout :cover-url="coverUrl" bottom-padding="220rpx">
<template #header>
<ActivityHeader
:title="detail.name || detail.title || '无限赏'"
:price="detail.price_draw"
price-unit="/发"
:cover-url="coverUrl"
:tags="['公开透明', '可验证']"
@show-rules="showRules"
@go-cabinet="goCabinet"
/>
</template>
<template #content>
<ActivityTabs v-model="tabActive" :stagger="1">
<template #pool>
<RewardsPreview
title="奖池配置"
:rewards="currentIssueRewards"
:grouped="true"
@view-all="rewardsVisible = true"
/>
</template>
<template #records>
<RecordsList :records="winRecords" />
</template>
</ActivityTabs>
</template>
<template #footer>
<!-- 底部多档位抽赏按钮 -->
<view class="bottom-actions">
<view class="tier-btn" @tap="openPayment(1)">
<text class="tier-price">¥{{ (pricePerDraw * 1).toFixed(2) }}</text>
<text class="tier-label">抽1发</text>
</view>
<view class="tier-btn" @tap="openPayment(3)">
<text class="tier-price">¥{{ (pricePerDraw * 3).toFixed(2) }}</text>
<text class="tier-label">抽3发</text>
</view>
<view class="tier-btn" @tap="openPayment(5)">
<text class="tier-price">¥{{ (pricePerDraw * 5).toFixed(2) }}</text>
<text class="tier-label">抽5发</text>
</view>
<view class="tier-btn tier-hot" @tap="openPayment(10)">
<text class="tier-price">¥{{ (pricePerDraw * 10).toFixed(2) }}</text>
<text class="tier-label">抽10发</text>
</view>
</view>
</template>
<template #modals>
<RewardsPopup
v-model:visible="rewardsVisible"
:title="`${currentIssueTitle} · 奖池与概率`"
:reward-groups="rewardGroups"
/>
<LotteryResultPopup
v-model:visible="showResultPopup"
:results="drawResults"
:show-retry-button="lastDrawUsedGamePass"
@close="onResultClose"
@retry="onRetryDraw"
/>
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:gamePasses="gamePasses"
:propCards="propCards"
@confirm="onPaymentConfirm"
/>
<RulesPopup
v-model:visible="rulesVisible"
:content="detail.gameplay_intro"
/>
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
<!-- 开奖加载弹窗 -->
<DrawLoadingPopup
:visible="showDrawLoading"
:progress="drawProgress"
:total="drawTotal"
/>
<GamePassPurchasePopup
v-model:visible="purchasePopupVisible"
:activity-id="activityId"
@success="onPurchaseSuccess"
/>
<!-- 悬浮次数卡入口 -->
<view v-if="gamePassRemaining > 0 || true" class="game-pass-float" @tap="openPurchasePopup">
<view class="badge-content">
<text class="badge-icon">🎮</text>
<text class="badge-text" v-if="gamePassRemaining > 0">{{ gamePassRemaining }}</text>
<text class="badge-text" v-else>购买</text>
</view>
<view class="badge-label">使用次数</view>
</view>
</template>
</ActivityPageLayout>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// - uni-app.vue
import ActivityPageLayout from '@/components/activity/ActivityPageLayout.vue'
import ActivityHeader from '@/components/activity/ActivityHeader.vue'
import ActivityTabs from '@/components/activity/ActivityTabs.vue'
import RewardsPreview from '@/components/activity/RewardsPreview.vue'
import RewardsPopup from '@/components/activity/RewardsPopup.vue'
import RecordsList from '@/components/activity/RecordsList.vue'
import RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import LotteryResultPopup from '@/components/activity/LotteryResultPopup.vue'
import DrawLoadingPopup from '@/components/activity/DrawLoadingPopup.vue'
import PaymentPopup from '@/components/PaymentPopup.vue'
import GamePassPurchasePopup from '@/components/GamePassPurchasePopup.vue'
import { getGamePasses } from '@/api/appUser'
// Composables
import { useActivity, useIssues, useRewards, useRecords } from '../../composables'
// API
import { joinLottery, createWechatOrder, getLotteryResult, getItemCards, getUserCoupons } from '@/api/appUser'
// ============ 使Composables ============
const activityId = ref('')
const {
detail,
coverUrl,
fetchDetail,
setNavigationTitle
} = useActivity(activityId)
const pricePerDraw = computed(() => Number(detail.value?.price_draw || 0) / 100)
const {
issues,
currentIssueId,
currentIssueTitle,
fetchIssues
} = useIssues(activityId)
const {
currentIssueRewards,
rewardGroups,
fetchRewardsForIssues
} = useRewards(activityId, currentIssueId)
const {
winRecords,
fetchWinRecords
} = useRecords()
// ============ ============
const tabActive = ref('pool')
const rewardsVisible = ref(false)
const rulesVisible = ref(false)
const cabinetVisible = ref(false)
const showResultPopup = ref(false)
const drawResults = ref([])
const drawLoading = ref(false)
const showDrawLoading = ref(false)
const drawProgress = ref(0)
const drawTotal = ref(1)
const lastDrawUsedGamePass = ref(false) // 使
const lastDrawCount = ref(1) //
//
const paymentVisible = ref(false)
const paymentAmount = ref('0.00')
const coupons = ref([])
const propCards = ref([])
const pendingCount = ref(1)
const selectedCoupon = ref(null)
const selectedCard = ref(null)
const useGamePassFlag = ref(false)
// ============ ============
const gamePasses = ref(null)
const gamePassRemaining = computed(() => gamePasses.value?.total_remaining || 0)
const purchasePopupVisible = ref(false)
async function fetchPasses() {
if (!activityId.value) return
try {
const res = await getGamePasses(activityId.value)
gamePasses.value = res || null
} catch (e) {
gamePasses.value = null
}
}
function openPurchasePopup() {
purchasePopupVisible.value = true
}
function onPurchaseSuccess() {
fetchPasses()
}
// ============ ============
function showRules() {
rulesVisible.value = true
}
function goCabinet() {
cabinetVisible.value = true
}
function onResultClose() {
showResultPopup.value = false
drawResults.value = []
}
function onRetryDraw() {
//
showResultPopup.value = false
drawResults.value = []
//
if (gamePassRemaining.value > 0) {
// 使
useGamePassFlag.value = true
selectedCoupon.value = null
selectedCard.value = null
onMachineDraw(lastDrawCount.value)
} else {
//
openPayment(lastDrawCount.value)
}
}
function openPayment(count) {
const times = Math.max(1, Number(count || 1))
pendingCount.value = times
paymentAmount.value = (pricePerDraw.value * times).toFixed(2)
const token = uni.getStorageSync('token')
// 使
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
if (!token || !hasPhoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
paymentVisible.value = true
//
Promise.all([fetchPropCards(), fetchCoupons()])
}
async function onPaymentConfirm(data) {
selectedCoupon.value = data?.coupon || null
selectedCard.value = data?.card || null
useGamePassFlag.value = data?.useGamePass || false
paymentVisible.value = false
await onMachineDraw(pendingCount.value)
}
async function fetchPropCards() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getItemCards(user_id)
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
// Group identical cards by name
const groupedMap = new Map()
list.forEach((i, idx) => {
const name = i.name ?? i.title ?? '道具卡'
if (!groupedMap.has(name)) {
groupedMap.set(name, {
id: i.id ?? i.card_id ?? String(idx),
name: name,
count: 0
})
}
groupedMap.get(name).count++
})
propCards.value = Array.from(groupedMap.values())
} catch (e) {
propCards.value = []
}
}
async function fetchCoupons() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getUserCoupons(user_id, 0, 1, 100)
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
coupons.value = list.map((i, idx) => {
const amountCents = i.remaining ?? i.amount ?? i.value ?? 0
const amt = isNaN(amountCents) ? 0 : (Number(amountCents) / 100)
return {
id: i.id ?? i.coupon_id ?? String(idx),
name: i.name ?? i.title ?? '优惠券',
amount: amt.toFixed(2)
}
})
} catch (e) {
coupons.value = []
}
}
function extractResultList(resultRes) {
const root = resultRes?.data ?? resultRes?.result ?? resultRes
if (!root) return []
// Backend now returns results array with all draw logs including doubled
if (resultRes?.results && Array.isArray(resultRes.results) && resultRes.results.length > 0) {
return resultRes.results
}
return root.results || root.list || root.items || root.data || []
}
function mapResultsToFlipItems(resultRes, poolRewards) {
const list = extractResultList(resultRes)
const poolArr = Array.isArray(poolRewards) ? poolRewards : []
const lookup = new Map()
poolArr.forEach(it => {
const id = it?.id ?? it?.reward_id ?? it?.product_id
if (id !== undefined) lookup.set(Number(id), it)
})
return list.filter(Boolean).map(d => {
const rewardId = d.reward_id ?? d.rewardId ?? d.product_id ?? d.productId ?? d.id
const rewardName = String(d.reward_name ?? d.rewardName ?? d.product_name ?? d.productName ?? d.title ?? d.name ?? '')
const fromId = Number.isFinite(Number(rewardId)) ? lookup.get(Number(rewardId)) : null
const fromName = !fromId && rewardName ? poolArr.find(x => x?.title === rewardName) : null
const it = fromId || fromName || null
return {
reward_id: rewardId, // reward_id
title: rewardName || it?.title || '奖励',
image: d.image || it?.image || d.img || d.pic || d.product_image || ''
}
})
}
async function onMachineDraw(count) {
const aid = activityId.value
const iid = currentIssueId.value
if (!aid || !iid) {
uni.showToast({ title: '期数未选择', icon: 'none' })
return
}
const token = uni.getStorageSync('token')
// 使
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
if (!token || !hasPhoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
const openid = uni.getStorageSync('openid')
if (!openid) {
uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' })
return
}
drawLoading.value = true
try {
const times = Math.max(1, Number(count || 1))
const joinRes = await joinLottery({
activity_id: Number(aid),
issue_id: Number(iid),
channel: 'miniapp',
count: times,
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0,
item_card_id: selectedCard.value?.id ? Number(selectedCard.value.id) : 0,
use_game_pass: useGamePassFlag.value
})
//
if (useGamePassFlag.value) {
fetchPasses()
}
const orderNo = joinRes?.order_no || joinRes?.data?.order_no || joinRes?.result?.order_no
if (!orderNo) throw new Error('未获取到订单号')
// Check if order is already paid (e.g. via Game Pass or Points)
const isPaid = (joinRes?.status === 2) || (joinRes?.actual_amount <= 0)
if (!isPaid) {
const payRes = await createWechatOrder({ openid, order_no: orderNo })
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
}
//
drawTotal.value = times
drawProgress.value = 0
showDrawLoading.value = true
//
let resultRes = await getLotteryResult(orderNo)
let pollCount = 0
const maxPolls = 15 // 15230
while (resultRes?.status === 'paid_waiting' &&
resultRes?.completed < resultRes?.count &&
pollCount < maxPolls) {
//
drawProgress.value = resultRes?.completed || 0
await new Promise(r => setTimeout(r, resultRes?.nextPollMs || 2000))
resultRes = await getLotteryResult(orderNo)
pollCount++
}
//
showDrawLoading.value = false
const items = mapResultsToFlipItems(resultRes, currentIssueRewards.value)
drawResults.value = items
// 使""
lastDrawUsedGamePass.value = useGamePassFlag.value
lastDrawCount.value = times
showResultPopup.value = true
} catch (e) {
showDrawLoading.value = false
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
drawLoading.value = false
}
}
// ============ ============
onLoad(async (opts) => {
const id = opts?.id || ''
if (!id) return
activityId.value = id
//
await Promise.all([fetchDetail(), fetchIssues()])
setNavigationTitle('无限赏')
//
await fetchRewardsForIssues(issues.value)
//
if (currentIssueId.value) {
fetchWinRecords(id, currentIssueId.value)
}
fetchPasses()
})
//
watch(currentIssueId, (newId) => {
if (newId && activityId.value) {
fetchWinRecords(activityId.value, newId)
}
})
</script>
<style lang="scss" scoped>
/* 底部多档位操作按钮 - 原始设计 */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 20rpx;
padding: 32rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(30rpx);
box-shadow: 0 -12rpx 40rpx rgba(0, 0, 0, 0.08);
z-index: 999;
border-top: 1rpx solid rgba(255, 255, 255, 0.8);
}
.tier-btn {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx 10rpx;
background: #FFF;
border: 2rpx solid rgba($brand-primary, 0.1);
border-radius: 28rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
margin: 0;
line-height: normal;
&::after {
border: none;
}
&:active {
transform: scale(0.92);
background: #F9F9F9;
box-shadow: none;
}
}
.tier-price {
font-size: 34rpx;
font-weight: 900;
color: $text-main;
font-family: 'DIN Alternate', sans-serif;
letter-spacing: -1rpx;
}
.tier-label {
font-size: 22rpx;
color: $brand-primary;
margin-top: 6rpx;
font-weight: 800;
font-style: italic;
}
/* 热门/最高档位 - 高级动效 */
.tier-hot {
background: $gradient-brand !important;
border: none !important;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35) !important;
position: relative;
overflow: hidden;
.tier-price {
color: #FFF !important;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.tier-label {
color: rgba(255, 255, 255, 0.9) !important;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
}
/* 流光效果 */
&::before {
content: '';
position: absolute;
top: -50%;
left: -150%;
width: 200%;
height: 200%;
background: linear-gradient(
to right,
transparent,
rgba(255, 255, 255, 0.25),
transparent
);
transform: rotate(30deg);
animation: shine 3s ease-in-out infinite;
}
&:active {
transform: scale(0.92);
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.25) !important;
}
}
@keyframes shine {
0% { left: -150%; }
50%, 100% { left: 150%; }
}
/* 翻牌弹窗 */
.flip-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
}
.flip-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10rpx);
}
.flip-content {
position: relative;
width: 90%;
max-height: 85vh;
background: rgba($bg-card, 0.95);
border-radius: $radius-xl;
padding: $spacing-lg;
overflow: hidden;
box-shadow: $shadow-card;
}
.overlay-close {
margin-top: $spacing-lg;
width: 100%;
background: $gradient-brand;
color: #fff;
border: none;
border-radius: $radius-lg;
font-size: $font-md;
font-weight: 600;
padding: $spacing-md;
&::after {
border: none;
}
}
/* 次数卡悬浮入口 */
.game-pass-float {
position: fixed;
right: 32rpx;
bottom: calc(180rpx + env(safe-area-inset-bottom));
z-index: 990;
display: flex;
flex-direction: column;
align-items: center;
animation: float 3s ease-in-out infinite;
}
.badge-content {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10rpx);
border-radius: 30rpx;
padding: 8rpx 16rpx;
display: flex;
align-items: center;
box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.15);
border: 1rpx solid rgba($brand-primary, 0.2);
}
.badge-icon { font-size: 28rpx; margin-right: 6rpx; }
.badge-text { font-size: 24rpx; font-weight: 800; color: $brand-primary; }
.badge-label {
font-size: 20rpx;
color: #fff;
background: $gradient-brand;
padding: 2rpx 8rpx;
border-radius: 8rpx;
margin-top: -6rpx;
z-index: 2;
box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.2);
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10rpx); }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
/**
* Composables 统一导出
*/
export { useActivity } from './useActivity'
export { useIssues } from './useIssues'
export { useRewards } from './useRewards'
export { useRecords } from './useRecords'

View File

@ -0,0 +1,72 @@
/**
* 活动数据管理 Composable
*/
import { ref, computed } from 'vue'
import { getActivityDetail } from '@/api/appUser'
import { cleanUrl } from '@/utils/format'
import { statusToText } from '@/utils/activity'
/**
* 活动数据管理
* @param {Ref<string>} activityIdRef - 活动ID的响应式引用
*/
export function useActivity(activityIdRef) {
const detail = ref({})
const loading = ref(false)
const coverUrl = computed(() => {
const d = detail.value || {}
return cleanUrl(d.image || d.banner || d.cover || '')
})
const statusText = computed(() => statusToText(detail.value?.status))
const pricePerDraw = computed(() => {
const cents = Number(detail.value?.price_draw || 0)
return cents / 100
})
const activityName = computed(() => {
const d = detail.value || {}
return d.name || d.title || ''
})
const scheduledTime = computed(() => detail.value?.scheduled_time || detail.value?.scheduledTime || '')
async function fetchDetail() {
const id = activityIdRef?.value || activityIdRef
console.log('[useActivity] fetchDetail called with activityId:', id)
if (!id) return
loading.value = true
try {
const data = await getActivityDetail(id)
detail.value = data || {}
console.log('[useActivity] getActivityDetail response:', data)
console.log('[useActivity] play_type:', data?.play_type)
} catch (e) {
console.error('fetchDetail error', e)
detail.value = {}
} finally {
loading.value = false
}
}
function setNavigationTitle(fallback = '活动') {
const title = activityName.value || fallback
try {
uni.setNavigationBarTitle({ title })
} catch (_) { }
}
return {
detail,
loading,
coverUrl,
statusText,
pricePerDraw,
activityName,
scheduledTime,
fetchDetail,
setNavigationTitle
}
}

View File

@ -0,0 +1,97 @@
/**
* 期数据管理 Composable
*/
import { ref, computed } from 'vue'
import { getActivityIssues } from '@/api/appUser'
import { normalizeIssues, pickLatestIssueId } from '@/utils/activity'
/**
* 期数据管理
* @param {Ref<string>} activityIdRef - 活动ID的响应式引用
*/
export function useIssues(activityIdRef) {
const issues = ref([])
const selectedIssueIndex = ref(0)
const loading = ref(false)
const currentIssueId = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
return (cur && cur.id) || ''
})
const currentIssue = computed(() => {
const arr = issues.value || []
return arr[selectedIssueIndex.value] || null
})
const currentIssueTitle = computed(() => {
const cur = currentIssue.value
if (!cur) return '-'
return cur.title || ('第' + (cur.no || '-') + '期')
})
const currentIssueStatusText = computed(() => {
const cur = currentIssue.value
return (cur && cur.status_text) || ''
})
async function fetchIssues() {
const id = activityIdRef?.value || activityIdRef
console.log('[useIssues] fetchIssues called with activityId:', id)
if (!id) {
console.warn('[useIssues] No activityId, skipping fetchIssues')
return
}
loading.value = true
try {
const data = await getActivityIssues(id)
console.log('[useIssues] getActivityIssues response:', data)
issues.value = normalizeIssues(data)
console.log('[useIssues] Normalized issues:', issues.value)
const latestId = pickLatestIssueId(issues.value)
console.log('[useIssues] Latest issue ID:', latestId)
setSelectedById(latestId)
console.log('[useIssues] currentIssueId after setSelectedById:', currentIssueId.value)
} catch (e) {
console.error('fetchIssues error', e)
issues.value = []
} finally {
loading.value = false
}
}
function setSelectedById(id) {
const arr = issues.value || []
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
selectedIssueIndex.value = idx
}
function prevIssue() {
const arr = issues.value || []
if (!arr.length) return
const next = Math.max(0, Number(selectedIssueIndex.value || 0) - 1)
selectedIssueIndex.value = next
}
function nextIssue() {
const arr = issues.value || []
if (!arr.length) return
const next = Math.min(arr.length - 1, Number(selectedIssueIndex.value || 0) + 1)
selectedIssueIndex.value = next
}
return {
issues,
selectedIssueIndex,
loading,
currentIssueId,
currentIssue,
currentIssueTitle,
currentIssueStatusText,
fetchIssues,
setSelectedById,
prevIssue,
nextIssue
}
}

View File

@ -0,0 +1,109 @@
/**
* 购买记录管理 Composable
*/
import { ref } from 'vue'
import { getIssueDrawLogs } from '@/api/appUser'
import { levelToAlpha } from '@/utils/activity'
/**
* 购买记录管理
*/
export function useRecords() {
const winRecords = ref([])
const loading = ref(false)
/**
* 获取购买记录
* @param {string} activityId - 活动ID
* @param {string} issueId - 期ID
*/
async function fetchWinRecords(activityId, issueId) {
if (!activityId || !issueId) return
loading.value = true
try {
const res = await getIssueDrawLogs(activityId, issueId)
const list = (res && res.list) || (Array.isArray(res) ? res : [])
// 直接使用原始记录列表,不进行聚合
// 映射字段以符合 RecordsList 组件的展示需求
winRecords.value = list.map(it => ({
id: it.id,
title: it.reward_name || it.title || it.name || '-', // 奖品名称
image: it.reward_image || it.image || '', // 奖品图片
count: 1, // 单个记录数量为1
// 用户信息
user_id: it.user_id,
user_name: it.user_name || '匿名用户',
avatar: cleanAvatar(it.avatar), // 清理 avatar 数据
// 时间信息
created_at: it.created_at,
// 其他元数据
is_winner: it.is_winner,
level: it.level,
level_name: getLevelName(it.level)
}))
} catch (e) {
console.error('fetchWinRecords error', e)
winRecords.value = []
} finally {
loading.value = false
}
}
function getLevelName(level) {
if (!level) return ''
const alpha = levelToAlpha(level)
return alpha + '赏'
}
/**
* 清理和验证 avatar 数据
* @param {string} avatar - 原始 avatar 数据可能是 base64 URL
* @returns {string} - 清理后的 avatar 数据
*/
function cleanAvatar(avatar) {
if (!avatar) return ''
// 如果是 base64 格式,确保格式正确
const avatarStr = String(avatar).trim()
// 检查是否已经是 data:image 格式
if (avatarStr.startsWith('data:image/')) {
return avatarStr
}
// 如果是 http(s) URL直接返回
if (avatarStr.startsWith('http://') || avatarStr.startsWith('https://')) {
return avatarStr
}
// 如果是相对路径,直接返回
if (avatarStr.startsWith('/')) {
return avatarStr
}
// 其他情况,可能是不完整的 base64尝试修复
// 如果不包含 data:image 前缀,添加默认的 png 前缀
if (avatarStr.match(/^[A-Za-z0-9+/=]+$/)) {
// 看起来像 base64 编码
return `data:image/png;base64,${avatarStr}`
}
return avatarStr
}
function clearRecords() {
winRecords.value = []
}
return {
winRecords,
loading,
fetchWinRecords,
clearRecords
}
}

View File

@ -0,0 +1,86 @@
/**
* 奖励数据管理 Composable
*/
import { ref, computed, watch } from 'vue'
import { getActivityIssueRewards } from '@/api/appUser'
import { normalizeRewards, groupRewardsByLevel } from '@/utils/activity'
import { cleanUrl } from '@/utils/format'
/**
* 奖励数据管理
* @param {Ref<string>} activityIdRef - 活动ID的响应式引用
* @param {Ref<string>} currentIssueIdRef - 当前期ID的响应式引用
*/
export function useRewards(activityIdRef, currentIssueIdRef) {
const rewardsMap = ref({})
const loading = ref(false)
const currentIssueRewards = computed(() => {
const issueId = currentIssueIdRef?.value || currentIssueIdRef
const m = rewardsMap.value || {}
return (issueId && Array.isArray(m[issueId])) ? m[issueId] : []
})
const rewardGroups = computed(() => {
return groupRewardsByLevel(currentIssueRewards.value)
})
/**
* 获取多期的奖励数据 (无缓存)
* @param {Array} issueList - 期列表
*/
async function fetchRewardsForIssues(issueList) {
const activityId = activityIdRef?.value || activityIdRef
if (!activityId) return
const toFetch = issueList || []
if (toFetch.length === 0) return
loading.value = true
try {
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
results.forEach((res, i) => {
const issueId = toFetch[i]?.id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value, cleanUrl) : []
rewardsMap.value = { ...rewardsMap.value, [issueId]: value }
})
} catch (e) {
console.error('fetchRewardsForIssues error', e)
} finally {
loading.value = false
}
}
/**
* 获取单期的奖励数据
* @param {string} issueId - 期ID
*/
async function fetchRewardsForIssue(issueId) {
const activityId = activityIdRef?.value || activityIdRef
if (!activityId || !issueId) return
loading.value = true
try {
const res = await getActivityIssueRewards(activityId, issueId)
const value = normalizeRewards(res, cleanUrl)
rewardsMap.value = { ...rewardsMap.value, [issueId]: value }
} catch (e) {
console.error('fetchRewardsForIssue error', e)
} finally {
loading.value = false
}
}
return {
rewardsMap,
loading,
currentIssueRewards,
rewardGroups,
fetchRewardsForIssues,
fetchRewardsForIssue
}
}

View File

@ -0,0 +1,326 @@
<template>
<view class="page">
<!-- 背景装饰 -->
<view class="bg-decoration"></view>
<!-- 头部 -->
<view class="header">
<text class="title">动物扫雷大作战</text>
</view>
<!-- 主内容 -->
<view class="content">
<!-- 游戏图标 -->
<view class="game-icon-box fadeInUp">
<text class="game-icon">💣</text>
<view class="game-glow"></view>
</view>
<!-- 游戏介绍 -->
<view class="glass-card intro-card fadeInUp" style="animation-delay: 0.1s;">
<text class="intro-title">多人对战扫雷</text>
<text class="intro-desc">快来挑战获胜领取礼品</text>
</view>
<!-- 资格显示 -->
<view class="glass-card ticket-card fadeInUp" v-if="!loading" style="animation-delay: 0.2s;">
<view class="ticket-row">
<text class="ticket-label">我的活动资格</text>
<view class="ticket-count-box">
<text class="ticket-count">{{ ticketCount }}</text>
<text class="ticket-unit"></text>
</view>
</view>
<view class="divider"></view>
<text class="ticket-tip">{{ ticketCount > 0 ? '每次进入消耗1次资格' : '完成任务或抖店购买指定链接可获赠活动资格哦~' }}</text>
</view>
<!-- 加载中 -->
<view v-else class="loading-box">
<text class="loading-text">加载中...</text>
</view>
</view>
<!-- 底部按钮 -->
<view class="footer">
<view
class="btn-primary"
:class="{ disabled: ticketCount <= 0 || entering }"
@tap="enterGame"
>
<text class="enter-btn-text">{{ entering ? '正在进入...' : (ticketCount > 0 ? '立即开局' : '资格不足') }}</text>
</view>
<view
class="btn-secondary"
style="margin-top: 24rpx; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 55rpx; height: 110rpx; display: flex; align-items: center; justify-content: center;"
@tap="goRoomList"
>
<text style="color: #94a3b8; font-size: 32rpx; font-weight: 600;">📡 对战列表 / 围观</text>
</view>
</view>
</view>
</template>
<script>
import { authRequest } from '../../../utils/request.js'
export default {
data() {
return {
loading: true,
ticketCount: 0,
entering: false,
gameCode: 'minesweeper'
}
},
onShow() {
this.loadTickets()
},
methods: {
async loadTickets() {
this.loading = true
try {
const userInfo = uni.getStorageSync('user_info') || {}
const userId = userInfo.id || userInfo.user_id
if (!userId) {
this.ticketCount = 0
return
}
const res = await authRequest({
url: `/api/app/users/${userId}/game_tickets`
})
this.ticketCount = res[this.gameCode] || 0
} catch (e) {
console.error('加载游戏资格失败', e)
this.ticketCount = 0
} finally {
this.loading = false
}
},
async enterGame() {
if (this.ticketCount <= 0 || this.entering) return
this.entering = true
try {
const res = await authRequest({
url: '/api/app/games/enter',
method: 'POST',
data: {
game_code: this.gameCode
}
})
const gameToken = encodeURIComponent(res.game_token)
const nakamaServer = encodeURIComponent(res.nakama_server)
const nakamaKey = encodeURIComponent(res.nakama_key)
uni.navigateTo({
url: `/pages-game/game/minesweeper/play?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
})
} catch (e) {
uni.showToast({
title: e.message || '进入游戏失败',
icon: 'none'
})
} finally {
this.entering = false
this.loadTickets()
}
},
async goRoomList() {
// nakama room-list URL
try {
const res = await authRequest({
url: '/api/app/games/enter',
method: 'POST',
data: {
game_code: this.gameCode
}
})
const gameToken = encodeURIComponent(res.game_token)
const nakamaServer = encodeURIComponent(res.nakama_server)
const nakamaKey = encodeURIComponent(res.nakama_key)
uni.navigateTo({
url: `/pages-game/game/minesweeper/room-list?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
})
} catch (e) {
uni.showToast({ title: '无法获取对战列表', icon: 'none' })
}
}
}
}
</script>
<style lang="scss">
@import '@/uni.scss';
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: $bg-page;
position: relative;
overflow: hidden;
}
.header {
position: relative;
z-index: 10;
display: flex;
align-items: center;
padding: 24rpx 32rpx;
padding-top: calc(80rpx + env(safe-area-inset-top));
}
.title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: 800;
color: $text-main;
margin-right: 80rpx;
}
.content {
flex: 1;
position: relative;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 40rpx;
}
.game-icon-box {
position: relative;
margin: 60rpx 0;
display: flex;
justify-content: center;
align-items: center;
}
.game-icon {
font-size: 180rpx;
animation: float 4s ease-in-out infinite;
z-index: 2;
}
.game-glow {
position: absolute;
width: 280rpx;
height: 280rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.25) 0%, transparent 70%);
filter: blur(20rpx);
animation: pulse 2s ease-in-out infinite;
}
.intro-card {
width: 100%;
padding: 48rpx;
text-align: center;
margin-bottom: 32rpx;
}
.intro-title {
font-size: 44rpx;
font-weight: 900;
color: $brand-primary;
display: block;
margin-bottom: 16rpx;
}
.intro-desc {
font-size: 28rpx;
color: $text-sub;
line-height: 1.5;
}
.ticket-card {
width: 100%;
padding: 40rpx;
}
.ticket-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.ticket-label {
font-size: 32rpx;
color: $text-main;
font-weight: 700;
}
.ticket-count-box {
display: flex;
align-items: baseline;
}
.ticket-count {
font-size: 64rpx;
font-weight: 900;
color: $brand-primary;
margin-right: 8rpx;
}
.ticket-unit {
font-size: 24rpx;
color: $text-sub;
}
.divider {
height: 1px;
background: rgba(0,0,0,0.05);
margin: 32rpx 0;
}
.ticket-tip {
font-size: 24rpx;
color: $text-sub;
display: block;
text-align: center;
}
.loading-box {
padding: 100rpx;
}
.loading-text {
font-size: 28rpx;
color: $text-sub;
}
.footer {
position: relative;
z-index: 10;
padding: 40rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.btn-primary {
height: 110rpx;
width: 100%;
&.disabled {
background: $text-disabled;
box-shadow: none;
pointer-events: none;
}
}
.enter-btn-text {
font-size: 36rpx;
letter-spacing: 2rpx;
}
/* Animations from App.vue are global, but we use local ones if needed */
.fadeInUp {
animation: fadeInUp 0.6s ease-out both;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,348 @@
<template>
<view class="page">
<view class="bg-decoration"></view>
<view class="header">
<text class="title">实时对战信号</text>
<view class="refresh-text-btn" :class="{ loading: loading }" @tap="loadRooms">
{{ loading ? '同步中...' : `刷新信号 (${countdown}s)` }}
</view>
</view>
<scroll-view scroll-y class="content" @refresherrefresh="loadRooms" :refresher-enabled="true" :refresher-triggered="isRefreshing">
<view v-if="rooms.length > 0" class="room-list">
<view v-for="room in rooms" :key="room.match_id" class="room-card glass-card fadeInUp">
<view class="room-main">
<view class="room-info">
<view class="room-header">
<text class="room-id">房间 #{{ room.match_id.split('.')[0].substring(0, 6) }}</text>
<view class="status-badge" :class="room.started ? 'started' : 'waiting'">
{{ room.started ? '进行中' : '等待中' }}
</view>
</view>
<view class="room-stats">
<view class="stat-item">
<text class="stat-icon">👥</text>
<text class="stat-text">{{ room.player_count }}/{{ room.max_players }} 玩家</text>
</view>
<view class="stat-item">
<text class="stat-icon">📡</text>
<text class="stat-text">延迟: {{ Math.floor(Math.random() * 50) + 20 }}ms</text>
</view>
</view>
</view>
<view class="room-actions">
<view v-if="!room.started && room.player_count < room.max_players" class="btn-action join" @tap="joinRoom(room)">
<text class="action-text">加入</text>
</view>
<view class="btn-action watch" @tap="watchRoom(room)">
<text class="action-text">围观</text>
</view>
</view>
</view>
</view>
</view>
<view v-else-if="!loading" class="empty-box">
<view class="empty-icon">🛰</view>
<text class="empty-text">未监测到活跃战局</text>
<view class="btn-primary start-new" @tap="goBack">去发起匹配</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { nakamaManager } from '../../../utils/nakamaManager.js';
import { authRequest } from '../../../utils/request.js';
export default {
data() {
return {
rooms: [],
loading: false,
isRefreshing: false,
gameToken: '',
nakamaServer: '',
nakamaKey: '',
refreshInterval: null,
countdownInterval: null,
countdown: 5
}
},
onLoad(options) {
this.gameToken = options.game_token;
this.nakamaServer = decodeURIComponent(options.nakama_server || '');
this.nakamaKey = decodeURIComponent(options.nakama_key || '');
this.initAndLoad();
//
this.countdownInterval = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
this.countdown = 5;
}
}, 1000);
// 5
this.refreshInterval = setInterval(() => {
this.loadRooms();
}, 5000);
},
onUnload() {
//
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
},
methods: {
async initAndLoad() {
this.loading = true;
try {
if (!nakamaManager.isConnected) {
nakamaManager.initClient(this.nakamaServer || 'wss://game.1024tool.vip', this.nakamaKey || 'defaultkey');
await nakamaManager.authenticateWithGameToken(this.gameToken);
}
await this.loadRooms();
} catch (e) {
uni.showToast({ title: '连接通讯中心失败', icon: 'none' });
} finally {
this.loading = false;
}
},
async loadRooms() {
this.isRefreshing = true;
try {
const res = await nakamaManager.rpc('list_matches', {});
this.rooms = res || [];
//
this.countdown = 5;
} catch (e) {
console.error('Failed to load rooms', e);
} finally {
this.isRefreshing = false;
this.loading = false;
}
},
goBack() {
uni.navigateBack();
},
joinRoom(room) {
// MatchID play.vue
uni.navigateTo({
url: `/pages-game/game/minesweeper/play?match_id=${room.match_id}&game_token=${encodeURIComponent(this.gameToken)}&nakama_server=${encodeURIComponent(this.nakamaServer)}&nakama_key=${encodeURIComponent(this.nakamaKey)}`
});
},
watchRoom(room) {
uni.navigateTo({
url: `/pages-game/game/minesweeper/play?match_id=${room.match_id}&is_spectator=1&game_token=${encodeURIComponent(this.gameToken)}&nakama_server=${encodeURIComponent(this.nakamaServer)}&nakama_key=${encodeURIComponent(this.nakamaKey)}`
});
}
}
}
</script>
<style lang="scss" scoped>
@import '@/uni.scss';
.page {
min-height: 100vh;
background-color: #0f172a;
color: #f8fafc;
display: flex;
flex-direction: column;
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 400rpx;
background: radial-gradient(circle at 50% 0%, rgba(59, 130, 246, 0.15) 0%, transparent 70%);
z-index: 0;
}
.header {
position: relative;
z-index: 10;
padding: 100rpx 40rpx 40rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 38rpx;
font-weight: 800;
letter-spacing: 2rpx;
color: #f8fafc;
}
.refresh-text-btn {
padding: 12rpx 24rpx;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
font-size: 24rpx;
color: #94a3b8;
transition: all 0.2s;
&.loading {
opacity: 0.6;
pointer-events: none;
}
&:active {
background: rgba(255, 255, 255, 0.1);
transform: scale(0.95);
}
}
.content {
flex: 1;
padding: 0 30rpx;
box-sizing: border-box;
}
.room-list {
padding-bottom: 60rpx;
}
.room-card {
margin-bottom: 24rpx;
padding: 32rpx;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.room-main {
display: flex;
justify-content: space-between;
align-items: center;
}
.room-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.room-id {
font-size: 28rpx;
font-weight: 600;
color: #94a3b8;
margin-right: 16rpx;
}
.status-badge {
padding: 4rpx 16rpx;
border-radius: 20rpx;
font-size: 20rpx;
font-weight: 700;
&.waiting {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
&.started {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
}
.room-stats {
display: flex;
gap: 24rpx;
}
.stat-item {
display: flex;
align-items: center;
}
.stat-icon {
font-size: 24rpx;
margin-right: 8rpx;
}
.stat-text {
font-size: 24rpx;
color: #cbd5e1;
}
.room-actions {
display: flex;
gap: 16rpx;
}
.btn-action {
padding: 16rpx 32rpx;
border-radius: 12rpx;
font-size: 24rpx;
font-weight: 700;
transition: all 0.2s;
&.join {
background: #3b82f6;
color: white;
box-shadow: 0 4rpx 12rpx rgba(59, 130, 246, 0.3);
}
&.watch {
background: rgba(255, 255, 255, 0.1);
color: #f8fafc;
border: 1px solid rgba(255, 255, 255, 0.2);
}
&:active {
transform: scale(0.95);
}
}
.empty-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 200rpx;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 40rpx;
opacity: 0.5;
}
.empty-text {
font-size: 32rpx;
color: #64748b;
margin-bottom: 60rpx;
}
.start-new {
width: 320rpx;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fadeInUp {
animation: fadeInUp 0.4s ease-out both;
}
</style>

View File

@ -0,0 +1,68 @@
<template>
<view class="container">
<web-view :src="url" @message="onMessage"></web-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const url = ref('')
onLoad((options) => {
if (options.url) {
let targetUrl = decodeURIComponent(options.url)
// Append auth info if not present
const token = uni.getStorageSync('token')
const uid = uni.getStorageSync('user_id')
const hasQuery = targetUrl.includes('?')
const separator = hasQuery ? '&' : '?'
// Append standard auth params for the game to consume
if (token) targetUrl += `${separator}token=${encodeURIComponent(token)}`
if (uid) targetUrl += `&uid=${encodeURIComponent(uid)}`
// Append ticket if provided
if (options.ticket) targetUrl += `&ticket=${encodeURIComponent(options.ticket)}`
console.log('Opening Game WebView:', targetUrl)
url.value = targetUrl
} else {
uni.showToast({ title: '游戏地址无效', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
})
function onMessage(e) {
console.log('Message from Game:', e.detail)
const data = e.detail.data || []
// Handle specific messages
data.forEach(msg => {
if (msg.action === 'close') {
uni.navigateBack()
} else if (msg.action === 'playAgain') {
// : token
console.log('PlayAgain: 返回游戏入口页面')
uni.navigateBack({
delta: 1,
success: () => {
// :
uni.$emit('refreshGame')
}
})
} else if (msg.action === 'game_over') {
// Optional: Refresh user balance or state
}
})
}
</script>
<style>
.container {
width: 100%;
height: 100vh;
}
</style>

379
pages-shop/shop/detail.vue Normal file
View File

@ -0,0 +1,379 @@
<template>
<view class="page">
<view class="bg-decoration"></view>
<view class="loading" v-if="loading">加载中...</view>
<view v-else-if="isOutOfStock" class="empty">商品库存不足由于市场价格存在波动请联系客服核实价格和补充库存</view>
<view v-else-if="detail.id" class="detail-wrap">
<image v-if="detail.main_image" class="main-image" :src="detail.main_image" mode="widthFix" />
<view class="info-card">
<view class="title">{{ detail.title || detail.name || '-' }}</view>
<view class="price-row">
<view class="points-wrap">
<text class="points-val">{{ formatPoints( detail.price) }}</text>
<text class="points-unit">积分</text>
</view>
</view>
<view class="stock" v-if="detail.stock !== null && detail.stock !== undefined">库存{{ detail.stock }}</view>
<view class="desc" v-if="detail.description">
<rich-text :nodes="detail.description"></rich-text>
</view>
</view>
</view>
<view v-else class="empty">商品不存在</view>
<!-- Action Bar Moved Outside info-card -->
<view class="action-bar-placeholder" v-if="detail.id"></view>
<view class="action-bar" v-if="detail.id">
<view
class="action-btn redeem"
:class="{ disabled: detail.stock === 0 }"
@tap="onRedeem"
>
{{ detail.stock === 0 ? '已售罄' : '立即兑换' }}
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getProductDetail } from '../../api/appUser'
import { redeemProductByPoints } from '../../utils/request.js'
const detail = ref({})
const loading = ref(false)
const isOutOfStock = ref(false)
function formatPrice(p) {
if (p === undefined || p === null) return '0.00'
return (Number(p) / 100).toFixed(2)
}
// -
function formatPoints(value) {
if (value === undefined || value === null) return '0.00'
const num = Number(value)
if (isNaN(num)) return '0.00'
// 1250 = 12.50
// 100
const finalValue = num / 100
// 使 Math.floor
return String(Math.floor(finalValue * 100) / 100).replace(/(\.\d)$/, '$10')
}
async function fetchDetail(id) {
loading.value = true
isOutOfStock.value = false
try {
const res = await getProductDetail(id)
detail.value = res || {}
console.log(detail.value);
if (detail.value.code === 20002 || detail.value.message === '商品缺货') {
// ,""
// request.js
isOutOfStock.value = true
console.log('[商品详情] 商品缺货')
}
} catch (e) {
// (code: 20002)
const errorCode = e?.data?.code || e?.code
const errorMessage = e?.data?.message || e?.message || e?.msg
if (errorCode === 20002 || errorMessage === '商品缺货') {
// ,""
// request.js
isOutOfStock.value = true
console.log('[商品详情] 商品缺货')
} else {
// ""
detail.value = {}
console.log('[商品详情] 错误信息:', e)
uni.showToast({ title: errorMessage || '加载失败', icon: 'none' })
}
} finally {
loading.value = false
}
}
function onBuy() {
uni.showToast({ title: '暂未开放购买', icon: 'none' })
}
async function onRedeem() {
const p = detail.value
if (!p || !p.id) return
//
if (p.stock === 0) {
uni.showModal({
title: '商品已售罄',
content: '该商品库存不足,请联系客服处理',
showCancel: false
})
return
}
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
const points = formatPoints(p.price)
uni.showModal({
title: '确认兑换',
content: `是否消耗 ${points} 积分兑换 ${p.title || p.name}`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '兑换中...' })
try {
const userId = uni.getStorageSync('user_id')
if (!userId) throw new Error('用户ID不存在')
await redeemProductByPoints(userId, p.id, 1)
uni.hideLoading()
uni.showModal({
title: '兑换成功',
content: `您已成功兑换 ${p.title || p.name}`,
showCancel: false,
success: () => {
fetchDetail(p.id)
}
})
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
}
}
}
})
}
onLoad((opts) => {
const id = opts && opts.id
if (id) fetchDetail(id)
})
</script>
<style lang="scss" scoped>
/* ============================================
柯大鸭潮玩 - 商品详情页
============================================ */
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: env(safe-area-inset-bottom);
position: relative;
overflow: hidden;
}
/* 背景装饰 - 漂浮光球 (与各主要页面统一) */
.bg-decoration {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
.loading, .empty {
text-align: center;
padding: 120rpx 40rpx;
color: $text-secondary;
font-size: $font-md;
position: relative;
z-index: 10;
}
.detail-wrap {
padding-bottom: 40rpx;
animation: fadeInUp 0.4s ease-out;
position: relative;
z-index: 1;
}
.main-image {
width: 100%;
height: 750rpx;
display: block;
background: $bg-secondary;
}
.info-card {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20rpx);
border-radius: $radius-xl $radius-xl 0 0;
padding: $spacing-xl;
box-shadow: 0 -8rpx 32rpx rgba(0,0,0,0.05);
position: relative;
z-index: 2;
margin-top: -40rpx;
min-height: 50vh;
border-top: 1px solid rgba(255, 255, 255, 0.6);
border-left: 1px solid rgba(255, 255, 255, 0.6);
border-right: 1px solid rgba(255, 255, 255, 0.6);
}
.title {
font-size: $font-xl;
font-weight: 800;
color: $text-main;
margin-bottom: $spacing-md;
line-height: 1.4;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.05);
}
.price-row {
display: flex;
align-items: baseline;
gap: $spacing-sm;
margin-bottom: $spacing-lg;
}
.price {
font-size: $font-xxl;
font-weight: 900;
color: $brand-primary;
font-family: 'DIN Alternate', sans-serif;
&::before {
content: '¥';
font-size: $font-md;
margin-right: 4rpx;
}
}
.points-wrap {
display: flex; align-items: baseline;
}
.points-val {
font-size: 48rpx;
font-weight: 900;
color: #FF9800;
font-family: 'DIN Alternate', sans-serif;
}
.points-unit {
font-size: 24rpx;
color: #FF9800;
margin-left: 6rpx;
font-weight: 600;
}
.stock {
font-size: $font-sm;
color: $text-secondary;
margin-bottom: $spacing-lg;
background: rgba(0,0,0,0.05);
display: inline-block;
padding: 6rpx $spacing-md;
border-radius: $radius-sm;
}
.desc {
font-size: $font-lg;
color: $text-main;
line-height: 1.8;
padding-top: $spacing-lg;
border-top: 1rpx dashed $border-color-light;
&::before {
content: '商品详情';
display: block;
font-size: $font-md;
color: $text-secondary;
margin-bottom: $spacing-sm;
font-weight: 700;
}
:deep(img) {
max-width: 100%;
height: auto;
display: block;
}
}
.action-bar-placeholder { height: 120rpx; }
.action-bar {
position: fixed;
bottom: 0; left: 0; right: 0;
padding: 20rpx 40rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: rgba(255,255,255,0.9);
backdrop-filter: blur(10px);
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
display: flex;
justify-content: flex-end;
z-index: 100;
}
.action-btn {
width: 100%;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
transition: transform 0.2s;
&:active { transform: scale(0.96); }
}
.action-btn.redeem {
background: linear-gradient(135deg, #FFB74D, #FF9800);
box-shadow: 0 8rpx 20rpx rgba(255, 152, 0, 0.3);
&.disabled {
background: #ccc;
box-shadow: none;
color: #999;
}
}
.action-btn.buy { background: linear-gradient(135deg, #FF6B6B, #FF3B30); box-shadow: 0 8rpx 20rpx rgba(255, 59, 48, 0.3); }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40rpx); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@ -8,17 +8,19 @@
<text class="label">手机号</text>
<input class="input" v-model="mobile" placeholder="请输入手机号" />
</view>
<view class="form-item">
<text class="label">省份</text>
<input class="input" v-model="province" placeholder="请输入省份" />
</view>
<view class="form-item">
<text class="label">城市</text>
<input class="input" v-model="city" placeholder="请输入城市" />
</view>
<view class="form-item">
<text class="label">区县</text>
<input class="input" v-model="district" placeholder="请输入区县" />
<view class="form-item region-picker" @click="openRegionPicker">
<text class="label">省市区</text>
<picker
mode="region"
:value="regionValue"
@change="onRegionChange"
@cancel="onRegionCancel"
>
<view class="picker-value" :class="{ placeholder: !hasRegion }">
{{ hasRegion ? `${province} ${city} ${district}` : '请选择省市区' }}
<text class="arrow-icon"></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="label">详细地址</text>
@ -34,7 +36,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { addAddress, updateAddress, listAddresses, setDefaultAddress } from '../../api/appUser'
@ -49,6 +51,25 @@ let isDefault = false
const loading = ref(false)
const error = ref('')
//
const regionValue = computed(() => [province.value, city.value, district.value])
const hasRegion = computed(() => province.value && city.value && district.value)
function onRegionChange(e) {
const values = e.detail.value
province.value = values[0] || ''
city.value = values[1] || ''
district.value = values[2] || ''
}
function onRegionCancel() {
// picker
}
function openRegionPicker() {
// picker
}
function fill(data) {
name.value = data.name || data.realname || ''
mobile.value = data.mobile || data.phone || ''
@ -150,7 +171,7 @@ onLoad((opts) => {
<style lang="scss" scoped>
/* ============================================
奇盒潮玩 - 地址编辑页面
柯大鸭潮玩 - 地址编辑页面
采用暖橙色调的表单设计
============================================ */
@ -193,6 +214,37 @@ onLoad((opts) => {
height: 48rpx;
}
/* 省市区选择器 */
.region-picker {
cursor: pointer;
picker {
flex: 1;
}
}
.picker-value {
display: flex;
align-items: center;
justify-content: space-between;
font-size: $font-md;
color: $text-main;
height: 48rpx;
line-height: 48rpx;
&.placeholder {
color: $text-tertiary;
}
}
.arrow-icon {
font-size: 36rpx;
color: $text-tertiary;
margin-left: 12rpx;
transform: rotate(0deg);
transition: transform 0.2s;
}
/* 提交按钮 */
.submit {
width: 100%;

View File

@ -0,0 +1,472 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">地址管理</view>
<view class="page-subtitle">Address Management</view>
</view>
<view class="action-bar">
<button class="add-btn" @click="toAdd">
<text class="plus-icon">+</text>
<text>新增收货地址</text>
</button>
</view>
<scroll-view scroll-y class="content-scroll">
<view v-if="error" class="error-tip">{{ error }}</view>
<!-- 空状态 -->
<view v-if="list.length === 0 && !loading" class="empty-state">
<text class="empty-icon">📍</text>
<text class="empty-text">暂无收货地址</text>
</view>
<!-- 地址列表 -->
<view class="addr-list">
<view
v-for="(item, index) in list"
:key="item.id"
class="addr-card"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<view class="addr-content" @click="toEdit(item)">
<view class="addr-header">
<view class="user-info">
<text class="name">{{ item.name || item.realname }}</text>
<text class="phone">{{ item.phone || item.mobile }}</text>
</view>
<view v-if="item.is_default" class="default-tag">默认</view>
</view>
<view class="addr-detail">
<text class="region">{{ item.province }} {{ item.city }} {{ item.district }}</text>
<text class="detail-text">{{ item.address || item.detail }}</text>
</view>
</view>
<!-- 分割线 -->
<view class="card-divider"></view>
<!-- 操作栏 -->
<view class="addr-actions">
<view class="action-left" @tap.stop="onSetDefault(item)">
<view class="radio-circle" :class="{ checked: item.is_default }"></view>
<text class="action-text">{{ item.is_default ? '默认地址' : '设为默认' }}</text>
</view>
<view class="action-right">
<view class="action-btn" @tap.stop="toEdit(item)">
<text class="btn-icon"></text>
<text>编辑</text>
</view>
<view class="action-btn delete" @tap.stop="onDelete(item)">
<text class="btn-icon">🗑</text>
<text>删除</text>
</view>
</view>
</view>
</view>
</view>
<view class="bottom-spacer"></view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { listAddresses, deleteAddress, setDefaultAddress } from '../../api/appUser'
const list = ref([])
const loading = ref(false)
const error = ref('')
async function fetchList() {
const user_id = uni.getStorageSync('user_id')
const token = uni.getStorageSync('token')
// 使
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
//
if (!user_id || !token || !hasPhoneBound) {
//
return
}
loading.value = true
error.value = ''
try {
const data = await listAddresses(user_id)
list.value = Array.isArray(data) ? data : (data && (data.list || data.items)) || []
} catch (e) {
//
console.error(e)
// error.value = ''
} finally {
loading.value = false
}
}
function toAdd() {
uni.removeStorageSync('edit_address')
uni.navigateTo({ url: '/pages-user/address/edit' })
}
function toEdit(item) {
uni.setStorageSync('edit_address', item)
uni.navigateTo({ url: `/pages-user/address/edit?id=${item.id}` })
}
function onDelete(item) {
const user_id = uni.getStorageSync('user_id')
uni.showModal({
title: '确认删除',
content: '确定删除该地址吗?',
success: async (res) => {
if (res.confirm) {
try {
await deleteAddress(user_id, item.id)
uni.showToast({ title: '已删除', icon: 'none' })
fetchList()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
async function onSetDefault(item) {
console.log('onSetDefault called', item.id, 'is_default:', item.is_default)
if (item.is_default) {
console.log('Already default, skipping')
return
}
try {
const user_id = uni.getStorageSync('user_id')
console.log('Calling setDefaultAddress API', user_id, item.id)
await setDefaultAddress(user_id, item.id)
uni.showToast({ title: '设置成功', icon: 'none' })
fetchList()
} catch (e) {
console.error('setDefaultAddress error', e)
uni.showToast({ title: '设置失败', icon: 'none' })
}
}
onLoad(() => {
fetchList()
})
onShow(() => {
fetchList()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 背景装饰 - 与优惠券页面统一 */
.bg-decoration {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;
text-transform: uppercase;
letter-spacing: 2rpx;
font-weight: 600;
}
.action-bar {
@extend .glass-card;
margin: 0 $spacing-lg $spacing-md;
padding: 20rpx;
z-index: 10;
}
.add-btn {
background: $gradient-brand;
color: #fff;
border: none;
border-radius: 100rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
box-shadow: $shadow-warm;
transition: all 0.3s;
&:active {
transform: scale(0.98);
opacity: 0.9;
}
}
.plus-icon {
font-size: 40rpx;
margin-right: 12rpx;
margin-top: -4rpx;
font-weight: 300;
}
.content-scroll {
flex: 1;
height: 0;
padding: 0 $spacing-lg;
box-sizing: border-box;
z-index: 1;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
opacity: 0.8;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
}
/* 地址列表 */
.addr-list {
padding-bottom: 40rpx;
}
.addr-card {
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
box-shadow: $shadow-sm;
overflow: hidden;
animation: fadeInUp 0.5s ease-out backwards;
/* Removed border from glass style */
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
.addr-content {
padding: 30rpx;
}
.addr-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 16rpx;
}
.name {
font-size: 32rpx;
font-weight: 700;
color: $text-main;
}
.phone {
font-size: 28rpx;
color: $text-sub;
font-family: monospace; /* 数字等宽 */
}
.default-tag {
font-size: 20rpx;
color: #fff;
background: linear-gradient(135deg, $brand-primary, $brand-secondary);
padding: 4rpx 12rpx;
border-radius: 8rpx 0 8rpx 0;
font-weight: 600;
box-shadow: 0 2rpx 6rpx rgba($brand-primary, 0.2);
}
.addr-detail {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.region {
font-size: 26rpx;
color: $text-sub;
}
.detail-text {
font-size: 28rpx;
color: $text-main;
line-height: 1.5;
font-weight: 500;
}
/* 分割线 */
.card-divider {
height: 1rpx;
background: #f0f0f0;
margin: 0 30rpx;
}
/* 操作栏 */
.addr-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx;
background: rgba(249, 249, 249, 0.5);
}
.action-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.radio-circle {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 2rpx solid #ddd;
position: relative;
transition: all 0.2s;
&.checked {
border-color: $brand-primary;
background: $brand-primary;
&::after {
content: '';
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 12rpx; height: 6rpx;
border-left: 3rpx solid #fff;
border-bottom: 3rpx solid #fff;
transform: translate(-50%, -60%) rotate(-45deg);
}
}
}
.action-text {
font-size: 24rpx;
color: $text-sub;
}
.action-right {
display: flex;
gap: 30rpx;
}
.action-btn {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 26rpx;
color: $text-sub;
padding: 10rpx;
.btn-icon {
font-size: 28rpx;
}
&:active {
opacity: 0.6;
}
&.delete {
color: $color-error; // 使
}
}
.error-tip {
color: #ff4d4f;
background: rgba(255, 77, 79, 0.1);
padding: 20rpx;
border-radius: 12rpx;
text-align: center;
font-size: 26rpx;
margin-bottom: 20rpx;
}
.bottom-spacer {
height: 40rpx;
}
</style>

View File

@ -0,0 +1,393 @@
<template>
<view class="container">
<view class="header glass-card">
<view class="title">填写收货信息</view>
<view class="desc">好友正在为您申请奖品发货请填写您的准确收货地址</view>
</view>
<!-- 已登录用户显示地址列表 -->
<view v-if="isLoggedIn && addressList.length > 0" class="address-list-section">
<view class="section-title">选择已保存的地址</view>
<view class="address-list">
<view
v-for="(addr, index) in addressList"
:key="addr.id || index"
class="address-card"
:class="{ selected: selectedAddressIndex === index }"
@tap="selectAddress(index)"
>
<view class="address-info">
<view class="address-header">
<text class="name">{{ addr.name }}</text>
<text class="mobile">{{ addr.mobile }}</text>
</view>
<view class="address-detail">
{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.address }}
</view>
</view>
<view class="address-check" v-if="selectedAddressIndex === index">
<text class="check-icon"></text>
</view>
</view>
</view>
<view class="divider">
<text class="divider-text">或填写新地址</text>
</view>
</view>
<view class="form glass-card">
<view class="form-item">
<text class="label">收货人</text>
<input v-model="form.name" placeholder="请输入姓名" class="input" />
</view>
<view class="form-item">
<text class="label">手机号码</text>
<input v-model="form.mobile" type="number" maxlength="11" placeholder="请输入手机号" class="input" />
</view>
<view class="form-item">
<text class="label">地区</text>
<picker mode="region" @change="onRegionChange" class="input">
<view class="picker-value" v-if="form.province">
{{ form.province }} {{ form.city }} {{ form.district }}
</view>
<view class="picker-placeholder" v-else>请选择省市区</view>
</picker>
</view>
<view class="form-item">
<text class="label">详细地址</text>
<textarea v-model="form.address" placeholder="街道、楼牌号等" class="textarea" />
</view>
</view>
<view class="footer-btn">
<button class="submit-btn" :loading="loading" @tap="onSubmit">确认提交</button>
</view>
<view class="tip-section">
<text class="tip-text">* 请确保信息准确提交后无法修改</text>
<text class="tip-text" v-if="isLoggedIn">* 您已登录提交后该奖品将转移至您的账户下</text>
<text class="tip-text" v-else>* 您当前未登录提交后资产仍归属于原发起人</text>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
import { listAddresses } from '@/api/appUser'
const token = ref('')
const loading = ref(false)
const isLoggedIn = ref(!!uni.getStorageSync('token'))
const addressList = ref([])
const selectedAddressIndex = ref(-1)
const form = reactive({
name: '',
mobile: '',
province: '',
city: '',
district: '',
address: ''
})
onLoad((options) => {
if (options.token) {
token.value = options.token
//
if (isLoggedIn.value) {
loadAddressList()
}
} else {
uni.showToast({ title: '参数错误', icon: 'none' })
}
})
//
async function loadAddressList() {
if (!isLoggedIn.value) return
try {
const userId = uni.getStorageSync('user_id')
if (!userId) return
const res = await listAddresses(userId)
addressList.value = res.list || res.data || res || []
} catch (e) {
console.error('获取地址列表失败:', e)
addressList.value = []
}
}
//
function selectAddress(index) {
selectedAddressIndex.value = index
const addr = addressList.value[index]
if (addr) {
form.name = addr.name || ''
form.mobile = addr.mobile || ''
form.province = addr.province || ''
form.city = addr.city || ''
form.district = addr.district || ''
form.address = addr.address || ''
}
}
function onRegionChange(e) {
const [p, c, d] = e.detail.value
form.province = p
form.city = c
form.district = d
//
selectedAddressIndex.value = -1
}
async function onSubmit() {
if (!token.value) return
if (!form.name || !form.mobile || !form.province || !form.address) {
uni.showToast({ title: '请完善收货信息', icon: 'none' })
return
}
if (!/^1\d{10}$/.test(form.mobile)) {
uni.showToast({ title: '手机号格式错误', icon: 'none' })
return
}
loading.value = true
try {
const res = await request({
url: '/api/app/address-share/submit',
method: 'POST',
data: {
share_token: token.value,
...form
},
// token request
header: {
'Authorization': uni.getStorageSync('token') || ''
}
})
uni.showModal({
title: '提交成功',
content: '收货信息已提交,请等待发货。' + (isLoggedIn.value ? '资产已转移至您的盒柜。' : ''),
showCancel: false,
success: () => {
// #ifdef MP-TOUTIAO
//
uni.switchTab({ url: '/pages/shop/index' })
// #endif
// #ifndef MP-TOUTIAO
uni.switchTab({ url: '/pages/index/index' })
// #endif
}
})
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.container {
padding: 30rpx;
min-height: 100vh;
background: $bg-page;
}
.header {
padding: 40rpx;
margin-bottom: 30rpx;
animation: fadeInDown 0.5s ease-out;
.title {
font-size: 36rpx;
font-weight: 700;
color: $text-main;
margin-bottom: 16rpx;
}
.desc {
font-size: 26rpx;
color: $text-sub;
line-height: 1.5;
}
}
/* 地址列表部分 */
.address-list-section {
margin-bottom: 30rpx;
animation: fadeInUp 0.5s ease-out 0.1s backwards;
}
.section-title {
font-size: 28rpx;
color: $text-main;
font-weight: 600;
margin-bottom: 20rpx;
padding: 0 10rpx;
}
.address-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 30rpx;
}
.address-card {
background: #fff;
border-radius: $radius-lg;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: $shadow-sm;
border: 2rpx solid transparent;
transition: all 0.3s;
&.selected {
border-color: $brand-primary;
background: rgba($brand-primary, 0.03);
}
&:active {
transform: scale(0.98);
}
}
.address-info {
flex: 1;
margin-right: 20rpx;
}
.address-header {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 12rpx;
.name {
font-size: 30rpx;
font-weight: 600;
color: $text-main;
}
.mobile {
font-size: 26rpx;
color: $text-sub;
}
}
.address-detail {
font-size: 26rpx;
color: $text-secondary;
line-height: 1.5;
}
.address-check {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.check-icon {
color: #fff;
font-size: 28rpx;
font-weight: bold;
}
}
.divider {
display: flex;
align-items: center;
margin: 30rpx 0;
&::before,
&::after {
content: '';
flex: 1;
height: 1rpx;
background: rgba(0, 0, 0, 0.1);
}
.divider-text {
padding: 0 20rpx;
font-size: 24rpx;
color: $text-tertiary;
}
}
.form {
padding: 20rpx 40rpx;
animation: fadeInUp 0.5s ease-out 0.2s backwards;
}
.form-item {
padding: 30rpx 0;
border-bottom: 1rpx solid rgba(0,0,0,0.05);
&:last-child { border-bottom: none; }
.label {
display: block;
font-size: 28rpx;
color: $text-main;
margin-bottom: 20rpx;
font-weight: 600;
}
.input, .textarea {
width: 100%;
font-size: 28rpx;
color: $text-main;
}
.textarea {
height: 160rpx;
padding: 0;
}
.picker-placeholder { color: $text-tertiary; }
}
.footer-btn {
margin-top: 60rpx;
padding: 0 40rpx;
}
.submit-btn {
height: 88rpx;
background: $gradient-brand;
color: #fff;
border-radius: $radius-round;
font-size: 32rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-warm;
&:active { transform: scale(0.98); opacity: 0.9; }
}
.tip-section {
margin-top: 40rpx;
padding: 0 40rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.tip-text {
font-size: 22rpx;
color: $text-tertiary;
}
</style>

View File

@ -0,0 +1,758 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">我的优惠券</view>
<view class="page-subtitle">My Coupons</view>
</view>
<!-- Tab栏 - 毛玻璃风格 -->
<view class="tab-bar glass-card">
<view class="tab-item" :class="{ active: currentTab === 1 }" @click="switchTab(1)">
<text class="tab-text">未使用</text>
<view class="tab-indicator" v-if="currentTab === 1"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 2 }" @click="switchTab(2)">
<text class="tab-text">已使用</text>
<view class="tab-indicator" v-if="currentTab === 2"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 3 }" @click="switchTab(3)">
<text class="tab-text">已过期</text>
<view class="tab-indicator" v-if="currentTab === 3"></view>
</view>
</view>
<!-- 内容区 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 加载状态 -->
<view v-if="loading && list.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="list.length === 0" class="empty-state">
<text class="empty-icon">🎟</text>
<text class="empty-text">{{ getEmptyText() }}</text>
</view>
<!-- 优惠券列表 -->
<view v-else class="coupon-list">
<view
v-for="(item, index) in list"
:key="item.id || index"
class="coupon-ticket"
:class="getCouponClass()"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<!-- 左侧金额区域 -->
<view class="coupon-left">
<view class="coupon-value">
<text class="coupon-symbol">¥</text>
<text class="coupon-amount">{{ formatValue(item.remaining ?? item.amount ?? 0) }}</text>
</view>
<text class="coupon-label">{{ currentTab === 1 ? '可用' : (currentTab === 2 ? '已用' : '过期') }}</text>
</view>
<!-- 中间分割线 -->
<view class="coupon-divider">
<view class="divider-notch top"></view>
<view class="divider-dash"></view>
<view class="divider-notch bottom"></view>
</view>
<!-- 右侧信息区域 -->
<view class="coupon-right">
<view class="coupon-header">
<text class="coupon-name">{{ item.name || '优惠券' }}</text>
<view class="coupon-original" v-if="item.amount && item.remaining !== undefined && item.remaining !== item.amount">
<text>原值 ¥{{ formatValue(item.amount) }}</text>
</view>
</view>
<text class="coupon-rules">{{ formatRules(item.rules) }}</text>
<!-- 使用进度条 -->
<view class="coupon-progress" v-if="item.amount && item.remaining !== undefined && item.remaining < item.amount">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: getUsedPercent(item) + '%' }"></view>
</view>
<text class="progress-text">已用 {{ formatValue(item.amount - item.remaining) }} ({{ getUsedPercent(item) }}%)</text>
</view>
<view class="coupon-footer">
<view class="footer-left">
<text class="coupon-expire">{{ formatExpiry(item) }}</text>
<text class="coupon-used-time" v-if="currentTab === 2 && item.used_at">使用时间{{ formatDateTime(item.used_at) }}</text>
</view>
</view>
<!-- 优化后的按钮位置 -->
<view class="coupon-action-wrapper" v-if="currentTab === 1">
<view class="use-btn" @click.stop="onUseCoupon(item)">
<text class="btn-text">去使用</text>
<view class="btn-shine"></view>
</view>
</view>
<view class="coupon-status" v-else>
<text class="status-tag" :class="currentTab === 2 ? 'used' : 'expired'">{{ currentTab === 2 ? '已使用' : '已过期' }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loading && list.length > 0" class="loading-more">
<view class="spinner"></view>
<text>加载更多...</text>
</view>
<view v-else-if="!hasMore && list.length > 0" class="no-more">- 到底啦 -</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import { getUserCoupons } from '../../api/appUser'
import { vibrateShort } from '@/utils/vibrate.js'
const list = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const currentTab = ref(1)
const page = ref(1)
const pageSize = 20
const hasMore = ref(true)
// ID
function getUserId() {
return uni.getStorageSync('user_id')
}
//
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
// ()
function formatValue(val) {
return (Number(val) / 100).toFixed(0)
}
//
function formatRules(rules) {
if (!rules) return '全场通用'
// "XXX""¥X.XX"
return rules.replace(/(\d+)分/g, (match, p1) => {
const yuan = (Number(p1) / 100).toFixed(2)
// .00
const formatted = yuan.endsWith('.00') ? yuan.slice(0, -3) : yuan
return `¥${formatted}`
})
}
//
function formatExpiry(item) {
// valid_end
const endTime = item.valid_end || item.end_time
if (!endTime) return '长期有效'
const d = new Date(endTime)
// Check for invalid date (e.g., "0001-01-01" from Go zero value)
if (isNaN(d.getTime()) || d.getFullYear() < 2000) return '长期有效'
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const label = currentTab.value === 3 ? '过期时间' : '有效期至'
return `${label} ${y}-${m}-${day}`
}
//
function formatDateTime(t) {
if (!t) return ''
const d = new Date(t)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
}
// 使
function getUsedPercent(item) {
if (!item.amount || !item.remaining) return 0
const used = item.amount - item.remaining
return Math.floor((used / item.amount) * 100)
}
//
function getEmptyText() {
if (currentTab.value === 1) return '暂无可用优惠券'
if (currentTab.value === 2) return '暂无使用记录'
return '暂无过期优惠券'
}
//
function getCouponClass() {
if (currentTab.value === 2) return 'coupon-used'
if (currentTab.value === 3) return 'coupon-expired'
return ''
}
// Tab
function switchTab(tab) {
if (currentTab.value === tab) return
vibrateShort()
currentTab.value = tab
list.value = []
page.value = 1
hasMore.value = true
fetchData()
}
//
async function onRefresh() {
isRefreshing.value = true
page.value = 1
hasMore.value = true
await fetchData(false)
isRefreshing.value = false
}
//
async function loadMore() {
if (loading.value || !hasMore.value) return
await fetchData(true)
}
//
async function fetchData(append = false) {
if (!checkAuth()) return
if (loading.value) return
loading.value = true
try {
const userId = getUserId()
// status: 0=unused, 1=used, 2=expired
const statusMap = { 1: 0, 2: 1, 3: 2 }
const res = await getUserCoupons(userId, statusMap[currentTab.value], page.value, pageSize)
const items = res.list || res.data || []
if (append) {
list.value = [...list.value, ...items]
} else {
list.value = items
}
if (items.length < pageSize) {
hasMore.value = false
} else {
page.value++
}
} catch (e) {
console.error('获取优惠券失败:', e)
hasMore.value = false
} finally {
loading.value = false
}
}
// 使
function onUseCoupon(item) {
vibrateShort()
// #ifdef MP-TOUTIAO
//
uni.switchTab({
url: '/pages/shop/index'
})
// #endif
// #ifndef MP-TOUTIAO
//
uni.switchTab({
url: '/pages/index/index'
})
// #endif
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
/* 背景装饰 - 漂浮光球 (与个人中心统一) */
.bg-decoration {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;
text-transform: uppercase;
letter-spacing: 2rpx;
font-weight: 600;
}
/* Tab栏 */
.tab-bar {
@extend .glass-card;
display: flex;
margin: 0 $spacing-lg;
padding: 8rpx;
}
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
position: relative;
transition: all 0.3s;
}
.tab-text {
font-size: 28rpx;
color: $text-sub;
font-weight: 500;
}
.tab-item.active .tab-text {
color: $text-main;
font-weight: 700;
}
.tab-indicator {
position: absolute;
bottom: 4rpx;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
background: $brand-primary;
border-radius: 6rpx;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 280rpx);
padding: $spacing-lg;
position: relative;
z-index: 1;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
}
/* 优惠券列表 */
.coupon-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 优惠券卡片 */
.coupon-ticket {
background: #fff;
border-radius: 16rpx;
display: flex;
overflow: hidden;
box-shadow: $shadow-sm;
position: relative;
animation: fadeInUp 0.5s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.coupon-left {
width: 180rpx;
background: linear-gradient(135deg, #FFF5E6, #fff);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx;
position: relative;
}
.coupon-value {
color: $brand-primary;
font-weight: 900;
}
.coupon-symbol {
font-size: 24rpx;
}
.coupon-amount {
font-size: 56rpx;
line-height: 1;
}
.coupon-label {
font-size: 20rpx;
color: $brand-primary;
margin-top: 8rpx;
border: 1px solid $brand-primary;
padding: 2rpx 8rpx;
border-radius: 6rpx;
}
/* 分割线 */
.coupon-divider {
width: 30rpx;
position: relative;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.divider-notch {
width: 24rpx;
height: 24rpx;
background: $bg-page;
border-radius: 50%;
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 2;
}
.divider-notch.top {
top: -12rpx;
}
.divider-notch.bottom {
bottom: -12rpx;
}
.divider-dash {
width: 0;
height: 80%;
border-left: 2rpx dashed #eee;
}
.coupon-right {
flex: 1;
padding: 24rpx;
padding-right: 130rpx; /* Prevent text overlap with button */
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
position: relative; /* Ensure padding works with absolute button */
}
.coupon-header {
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.coupon-name {
font-size: $font-md;
font-weight: 700;
color: $text-main;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.coupon-original {
font-size: 20rpx;
color: $text-tertiary;
text-decoration: line-through;
margin-left: 8rpx;
}
.coupon-rules {
font-size: $font-xs;
color: $text-sub;
margin-bottom: 16rpx;
}
/* 进度条 */
.coupon-progress {
margin-bottom: 12rpx;
}
.progress-bar {
height: 6rpx;
background: $bg-secondary;
border-radius: 100rpx;
overflow: hidden;
margin-bottom: 4rpx;
}
.progress-fill {
height: 100%;
background: $brand-primary;
border-radius: 100rpx;
}
.progress-text {
font-size: 18rpx;
color: $text-tertiary;
}
.coupon-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12rpx;
}
.footer-left {
display: flex;
flex-direction: column;
}
.coupon-expire {
font-size: 20rpx;
color: $text-tertiary;
}
.coupon-used-time {
font-size: 18rpx;
color: $text-tertiary;
margin-top: 4rpx;
}
/* 优化后的按钮样式 */
.coupon-action-wrapper {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
z-index: 10;
}
.use-btn {
background: linear-gradient(135deg, #FF8D3F, #FF5C00);
padding: 12rpx 32rpx;
border-radius: 40rpx;
box-shadow: 0 6rpx 20rpx rgba(255, 92, 0, 0.3);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
&:active {
transform: scale(0.92);
box-shadow: 0 2rpx 10rpx rgba(255, 92, 0, 0.2);
}
}
.btn-text {
color: #fff;
font-size: 24rpx;
font-weight: 700;
letter-spacing: 2rpx;
position: relative;
z-index: 2;
}
.btn-shine {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 100%
);
transform: skewX(-25deg);
animation: shine 3s infinite;
}
@keyframes shine {
0% { left: -100%; }
20%, 100% { left: 150%; }
}
.status-tag {
font-size: 22rpx;
color: $text-tertiary;
background: #F5F5F5;
padding: 6rpx 16rpx;
border-radius: 8rpx;
margin-left: auto;
}
/* 过期/已使用状态 */
.coupon-used .coupon-left,
.coupon-expired .coupon-left {
background: #f9f9f9;
}
.coupon-used .coupon-value,
.coupon-expired .coupon-value,
.coupon-used .coupon-label,
.coupon-expired .coupon-label {
color: $text-tertiary;
border-color: $text-tertiary;
}
.coupon-used .coupon-name,
.coupon-expired .coupon-name {
color: $text-sub;
}
/* 加载更多 */
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
color: $text-tertiary;
font-size: 24rpx;
gap: 12rpx;
}
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.no-more {
text-align: center;
padding: 40rpx 0;
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
</style>

View File

@ -14,8 +14,8 @@
<script>
export default {
methods: {
toUser() { uni.navigateTo({ url: '/pages/agreement/user' }) },
toPurchase() { uni.navigateTo({ url: '/pages/agreement/purchase' }) }
toUser() { uni.navigateTo({ url: '/pages-user/agreement/user' }) },
toPurchase() { uni.navigateTo({ url: '/pages-user/agreement/purchase' }) }
}
}
</script>

View File

@ -0,0 +1,538 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<!-- 装饰球体 -->
<view class="orb orb-1"></view>
<view class="orb orb-2"></view>
<view class="content-wrap">
<!-- 品牌区域 -->
<view class="glass-card hero-card">
<view class="brand-section">
<view class="logo-box">
<image class="logo" src="/static/logo.png" mode="widthFix"></image>
</view>
<view class="hero-title">🎁 好友邀请</view>
<view class="welcome-text">开启欧气之旅 </view>
</view>
<!-- 邀请人信息 -->
<view class="invite-info" v-if="inviteCode">
<view class="invite-badge">
<text class="invite-emoji">👋</text>
<view class="invite-detail">
<text class="invite-main">好友正在邀请你加入</text>
<text class="invite-code">邀请码{{ inviteCode }}</text>
</view>
</view>
</view>
</view>
<!-- 福利卡片 -->
<view class="glass-card benefits-card">
<view class="benefits-header">
<text class="benefits-title">🎉 新人专属福利</text>
</view>
<view class="benefits-list">
<view class="benefit-item">
<view class="benefit-icon-wrap">
<text class="benefit-icon">💎</text>
</view>
<view class="benefit-text">
<text class="benefit-main">注册即送10积分</text>
<text class="benefit-sub">可用于抽奖抵扣</text>
</view>
</view>
<view class="benefit-item">
<view class="benefit-icon-wrap">
<text class="benefit-icon">🎫</text>
</view>
<view class="benefit-text">
<text class="benefit-main">首单专属优惠</text>
<text class="benefit-sub">限时折扣等你拿</text>
</view>
</view>
<view class="benefit-item">
<view class="benefit-icon-wrap">
<text class="benefit-icon">🃏</text>
</view>
<view class="benefit-text">
<text class="benefit-main">新手道具卡</text>
<text class="benefit-sub">免费体验玩法</text>
</view>
</view>
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<view class="action-section">
<button class="btn login-btn" open-type="getPhoneNumber" :disabled="loading" @getphonenumber="onGetPhoneNumber">
<text class="btn-text">🚀 微信一键加入</text>
<view class="btn-shine"></view>
</button>
</view>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="action-section">
<button class="btn login-btn" @click="goLogin">
<text class="btn-text">🚀 立即加入</text>
</button>
</view>
<!-- #endif -->
<!-- 协议区 -->
<view class="agreements">
<view class="checkbox-area" @click="toggleAgreement">
<view class="checkbox round" :class="{ checked: agreementChecked }">
<view class="check-mark" v-if="agreementChecked"></view>
</view>
</view>
<view class="agreement-text">
登录即代表同意 <text class="link" @tap="toUserAgreement">用户协议</text> & <text class="link" @tap="toPurchaseAgreement">隐私政策</text>
</view>
</view>
<view v-if="error" class="error-toast">{{ error }}</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { wechatLogin, bindPhone, getUserStats, getPointsBalance } from '../../api/appUser'
const loading = ref(false)
const error = ref('')
const inviteCode = ref('')
const agreementChecked = ref(false)
onLoad((options) => {
const code = options.invite_code || options.inviteCode || ''
if (code) {
inviteCode.value = code
uni.setStorageSync('inviter_code', code)
}
})
function toggleAgreement() {
agreementChecked.value = !agreementChecked.value
}
function toUserAgreement() { uni.navigateTo({ url: '/pages-user/agreement/user' }) }
function toPurchaseAgreement() { uni.navigateTo({ url: '/pages-user/agreement/purchase' }) }
function goLogin() {
uni.navigateTo({ url: '/pages/login/index' })
}
function onGetPhoneNumber(e) {
if (!agreementChecked.value) {
uni.showToast({ title: '请先阅读并同意协议', icon: 'none' })
return
}
const phoneCode = e.detail.code
if (!phoneCode) {
uni.showToast({ title: '未授权手机号', icon: 'none' })
return
}
loading.value = true
error.value = ''
uni.login({
provider: 'weixin',
success: async (res) => {
try {
const loginCode = res.code
const inviterCode = uni.getStorageSync('inviter_code')
const data = await wechatLogin(loginCode, inviterCode)
const token = data && data.token
const user_id = data && data.user_id
const user_info = data || {}
uni.setStorageSync('user_info', user_info)
if (token) uni.setStorageSync('token', token)
if (user_id) uni.setStorageSync('user_id', user_id)
if (user_info.avatar) uni.setStorageSync('avatar', user_info.avatar)
if (user_info.nickname) uni.setStorageSync('nickname', user_info.nickname)
if (user_info.invite_code) uni.setStorageSync('invite_code', user_info.invite_code)
const openid = data && (data.openid || data.open_id)
if (openid) uni.setStorageSync('openid', openid)
try {
await new Promise(r => setTimeout(r, 600))
const bindRes = await bindPhone(user_id, phoneCode, { 'X-Suppress-Auth-Modal': true })
const phoneNumber = (bindRes && (bindRes.phone || bindRes.phone_number || bindRes.mobile)) || ''
if (phoneNumber) {
uni.setStorageSync('phone_number', phoneNumber)
console.log('[Invite Landing] 已缓存手机号:', phoneNumber)
}
} catch (bindErr) {
console.warn('Bind phone failed', bindErr)
}
try {
const stats = await getUserStats(user_id)
uni.setStorageSync('user_stats', stats)
const balance = await getPointsBalance(user_id)
const b = balance && balance.balance !== undefined ? balance.balance : balance
uni.setStorageSync('points_balance', b)
} catch(e) {}
uni.showToast({ title: '欢迎加入!', icon: 'success' })
setTimeout(() => {
// #ifdef MP-TOUTIAO
//
uni.switchTab({ url: '/pages/shop/index' })
// #endif
// #ifndef MP-TOUTIAO
uni.switchTab({ url: '/pages/index/index' })
// #endif
}, 500)
} catch (err) {
error.value = err.message || '登录失败'
} finally {
loading.value = false
}
},
fail: () => {
error.value = '微信登录失败'
loading.value = false
}
})
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-secondary;
position: relative;
overflow: hidden;
}
/* 装饰光球 - 与登录页保持一致 */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80rpx);
opacity: 0.6;
pointer-events: none;
}
.orb-1 {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.4), transparent 70%);
top: -100rpx;
left: -100rpx;
animation: float 8s ease-in-out infinite;
}
.orb-2 {
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.3), transparent 70%);
bottom: -150rpx;
right: -150rpx;
animation: float 10s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 40rpx); }
}
.content-wrap {
position: relative;
z-index: 1;
padding: 40rpx;
padding-top: calc(env(safe-area-inset-top) + 40rpx);
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40rpx); }
to { opacity: 1; transform: translateY(0); }
}
/* Hero Card */
.hero-card {
padding: 60rpx 40rpx;
margin-bottom: $spacing-lg;
}
.brand-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40rpx;
}
.logo-box {
width: 160rpx;
height: 160rpx;
background: $bg-card;
border-radius: 40rpx;
padding: 20rpx;
box-shadow: 0 12rpx 30rpx rgba($brand-primary, 0.2);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: $spacing-xl;
animation: pulse 3s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); box-shadow: 0 12rpx 30rpx rgba($brand-primary, 0.2); }
50% { transform: scale(1.02); box-shadow: 0 16rpx 40rpx rgba($brand-primary, 0.3); }
}
.logo {
width: 100%;
height: 100%;
}
.hero-title {
font-size: 44rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 12rpx;
letter-spacing: 2rpx;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.05);
}
.welcome-text {
font-size: 26rpx;
color: $text-sub;
letter-spacing: 4rpx;
opacity: 0.8;
}
/* 邀请人信息 */
.invite-info {
margin-top: 20rpx;
}
.invite-badge {
display: flex;
align-items: center;
background: rgba($brand-primary, 0.08);
border-radius: $radius-lg;
padding: 20rpx 24rpx;
border: 1rpx solid rgba($brand-primary, 0.15);
}
.invite-emoji {
font-size: 48rpx;
margin-right: 20rpx;
}
.invite-detail {
display: flex;
flex-direction: column;
}
.invite-main {
font-size: 28rpx;
font-weight: 600;
color: $text-main;
margin-bottom: 4rpx;
}
.invite-code {
font-size: 24rpx;
color: $text-sub;
}
/* Benefits Card */
.benefits-card {
padding: 40rpx;
margin-bottom: $spacing-lg;
}
.benefits-header {
text-align: center;
margin-bottom: 32rpx;
}
.benefits-title {
font-size: 32rpx;
font-weight: 700;
color: $text-main;
}
.benefits-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.benefit-item {
display: flex;
align-items: center;
background: $bg-card;
border-radius: $radius-lg;
padding: 24rpx;
box-shadow: $shadow-sm;
transition: transform 0.2s;
&:active {
transform: scale(0.98);
}
}
.benefit-icon-wrap {
width: 80rpx;
height: 80rpx;
background: rgba($brand-primary, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.benefit-icon {
font-size: 40rpx;
}
.benefit-text {
display: flex;
flex-direction: column;
}
.benefit-main {
font-size: 28rpx;
font-weight: 600;
color: $text-main;
margin-bottom: 6rpx;
}
.benefit-sub {
font-size: 22rpx;
color: $text-sub;
}
/* Action Section */
.action-section {
margin-bottom: $spacing-lg;
}
.btn {
height: 96rpx;
border-radius: $radius-round;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-lg;
font-weight: 800;
position: relative;
overflow: hidden;
transition: all 0.2s;
&:active { transform: scale(0.96); }
}
.login-btn {
background: $gradient-brand;
color: $text-inverse;
box-shadow: 0 10rpx 30rpx rgba($brand-primary, 0.3);
border: none;
}
.btn-text {
position: relative;
z-index: 1;
}
.btn-shine {
position: absolute;
top: 0; left: -100%;
width: 50%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
transform: skewX(-20deg);
animation: shine 3s infinite;
}
@keyframes shine {
0% { left: -100%; }
20% { left: 200%; }
100% { left: 200%; }
}
/* Agreements */
.agreements {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 0 20rpx;
}
.checkbox-area {
padding-right: 12rpx;
}
.checkbox {
width: 36rpx;
height: 36rpx;
border: 3rpx solid $border-color;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
background: rgba(255,255,255,0.5);
&.checked {
background: $brand-primary;
border-color: $brand-primary;
box-shadow: 0 4rpx 10rpx rgba($brand-primary, 0.3);
}
}
.check-mark {
color: $text-inverse;
font-size: $font-sm;
font-weight: bold;
}
.agreement-text {
font-size: $font-sm;
color: $text-tertiary;
line-height: 1.5;
text-align: left;
}
.link {
color: $brand-primary;
text-decoration: none;
font-weight: 600;
margin: 0 4rpx;
}
.error-toast {
position: fixed;
top: 100rpx;
left: 50%;
transform: translateX(-50%);
background: rgba($uni-color-error, 0.9);
color: $text-inverse;
padding: 16rpx 32rpx;
border-radius: 12rpx;
font-size: 26rpx;
z-index: 999;
box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.2);
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from { transform: translate(-50%, -100%); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
}
</style>

View File

@ -0,0 +1,403 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">邀请记录</view>
<view class="page-subtitle">Invitations</view>
</view>
<!-- 统计卡片 - 毛玻璃风格 -->
<view class="stats-card glass-card">
<view class="stat-item">
<text class="stat-num">{{ list.length }}</text>
<text class="stat-label">邀请人数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-num">{{ getRewardsTotal() }}</text>
<text class="stat-label">累计奖励</text>
</view>
</view>
<!-- 内容区 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 加载状态 -->
<view v-if="loading && list.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="list.length === 0" class="empty-state">
<text class="empty-icon">👥</text>
<text class="empty-text">暂无邀请记录</text>
<text class="empty-hint">分享给好友一起来玩吧</text>
</view>
<!-- 邀请列表 -->
<view v-else class="invite-list">
<view
v-for="(item, index) in list"
:key="item.id || index"
class="invite-item"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<image class="invite-avatar" :src="item.avatar || '/static/logo.png'" mode="aspectFill"></image>
<view class="invite-info">
<text class="invite-name">{{ item.nickname || '用户' + item.id }}</text>
<text class="invite-time">{{ formatDate(item.created_at) }}</text>
</view>
<view class="invite-status">
<text class="status-text">已邀请</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loading && list.length > 0" class="loading-more">
<view class="spinner"></view>
<text>加载更多...</text>
</view>
<view v-else-if="!hasMore && list.length > 0" class="no-more">- 到底啦 -</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getUserInvites } from '../../api/appUser'
const list = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const page = ref(1)
const pageSize = 20
const hasMore = ref(true)
// ID
function getUserId() {
return uni.getStorageSync('user_id')
}
//
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
//
function formatDate(t) {
if (!t) return ''
const d = new Date(t)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
//
function getRewardsTotal() {
// ×
const rewardPerInvite = 10 //
return list.value.length * rewardPerInvite
}
//
async function onRefresh() {
isRefreshing.value = true
page.value = 1
hasMore.value = true
await fetchData(false)
isRefreshing.value = false
}
//
async function loadMore() {
if (loading.value || !hasMore.value) return
await fetchData(true)
}
//
async function fetchData(append = false) {
if (!checkAuth()) return
if (loading.value) return
loading.value = true
try {
const userId = getUserId()
const res = await getUserInvites(userId, page.value, pageSize)
const items = res.list || res.data || []
if (append) {
list.value = [...list.value, ...items]
} else {
list.value = items
}
if (items.length < pageSize) {
hasMore.value = false
} else {
page.value++
}
} catch (e) {
console.error('获取邀请记录失败:', e)
hasMore.value = false
} finally {
loading.value = false
}
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;
text-transform: uppercase;
letter-spacing: 2rpx;
font-weight: 600;
}
/* 统计卡片 */
.stats-card {
@extend .glass-card;
margin: 0 $spacing-lg $spacing-lg;
padding: 40rpx;
display: flex;
justify-content: center;
align-items: center;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-num {
font-size: 56rpx;
font-weight: 900;
color: $brand-primary;
font-family: 'DIN Alternate', sans-serif;
line-height: 1;
margin-bottom: 12rpx;
}
.stat-label {
font-size: 24rpx;
color: $text-sub;
}
.stat-divider {
width: 1px;
height: 60rpx;
background: $border-color-light;
margin: 0 40rpx;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 400rpx);
padding: 0 $spacing-lg $spacing-lg;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
margin-bottom: 12rpx;
}
.empty-hint {
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
/* 邀请列表 */
.invite-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.invite-item {
background: #fff;
border-radius: $radius-lg;
padding: 24rpx;
display: flex;
align-items: center;
box-shadow: $shadow-sm;
animation: fadeInUp 0.5s ease-out backwards;
&:active {
transform: scale(0.98);
background: rgba(255, 255, 255, 0.8);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.invite-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: $bg-secondary;
margin-right: 24rpx;
flex-shrink: 0;
}
.invite-info {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.invite-name {
font-size: 30rpx;
font-weight: 700;
color: $text-main;
margin-bottom: 8rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.invite-time {
font-size: 24rpx;
color: $text-tertiary;
}
.invite-status {
flex-shrink: 0;
}
.status-text {
font-size: 24rpx;
color: $uni-color-success;
background: rgba($uni-color-success, 0.1);
padding: 6rpx 16rpx;
border-radius: 100rpx;
}
/* 加载更多 */
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
color: $text-tertiary;
font-size: 24rpx;
gap: 12rpx;
}
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.no-more {
text-align: center;
padding: 40rpx 0;
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
</style>

View File

@ -0,0 +1,718 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">我的道具卡</view>
<view class="page-subtitle">My Item Cards</view>
</view>
<!-- Tab栏 - 毛玻璃风格 -->
<view class="tab-bar glass-card">
<view class="tab-item" :class="{ active: currentTab === 0 }" @click="switchTab(0)">
<text class="tab-text">未使用</text>
<view class="tab-indicator" v-if="currentTab === 0"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 1 }" @click="switchTab(1)">
<text class="tab-text">已使用</text>
<view class="tab-indicator" v-if="currentTab === 1"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 2 }" @click="switchTab(2)">
<text class="tab-text">已过期</text>
<view class="tab-indicator" v-if="currentTab === 2"></view>
</view>
</view>
<!-- 内容区 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 加载状态 -->
<view v-if="loading && list.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="list.length === 0" class="empty-state">
<text class="empty-icon">🃏</text>
<text class="empty-text">{{ getEmptyText() }}</text>
</view>
<!-- 道具卡列表 -->
<view v-else class="item-list">
<view
v-for="(item, index) in list"
:key="item.id || index"
class="item-ticket"
:class="{ 'used': currentTab === 1, 'expired': currentTab === 2 }"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<!-- 左侧图标区域 -->
<view class="ticket-left">
<view class="card-icon-wrap">
<text class="card-icon">{{ getCardIcon(item.type || item.name) }}</text>
</view>
</view>
<!-- 中间分割线 -->
<view class="ticket-divider">
<view class="divider-notch top"></view>
<view class="divider-dash"></view>
<view class="divider-notch bottom"></view>
</view>
<!-- 右侧信息区域 -->
<view class="ticket-right">
<view class="card-info">
<text class="card-name">{{ item.name || item.title || '道具卡' }}</text>
<text class="card-desc">{{ item.description || item.rules || '可在抽奖时使用' }}</text>
<view class="usage-info" v-if="currentTab === 1">
<text class="card-use-time" v-if="item.used_at">使用时间{{ formatDateTime(item.used_at) }}</text>
<view class="usage-detail" v-if="item.used_activity_name">
<text class="detail-label">使用于</text>
<text class="detail-val">{{ item.used_activity_name }}</text>
<text class="detail-val" v-if="item.used_issue_number"> - 期号 {{ item.used_issue_number }}</text>
</view>
<view class="usage-detail" v-if="item.used_reward_name">
<text class="detail-label">效果</text>
<text class="detail-val highlight">{{ item.used_reward_name }}</text>
</view>
</view>
<view class="usage-info" v-if="currentTab === 2">
<text class="card-use-time" v-if="item.valid_end">过期时间{{ formatDateTime(item.valid_end) }}</text>
</view>
<!-- Unused State: Show Validity -->
<view class="usage-info" v-if="currentTab === 0">
<text class="card-use-time" v-if="item.valid_end">有效期至{{ formatDateTime(item.valid_end) }}</text>
</view>
</view>
<!-- 优化后的按钮位置 -->
<view class="ticket-action-wrapper" v-if="currentTab === 0">
<view class="use-btn" @click.stop="onUseCard(item)">
<text class="btn-text">去使用</text>
<view class="btn-shine"></view>
</view>
</view>
<view class="card-used-badge" v-else-if="currentTab === 1">
<text class="used-text">已使用</text>
</view>
<view class="card-used-badge expired" v-else-if="currentTab === 2">
<text class="used-text">已过期</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loading && list.length > 0" class="loading-more">
<view class="spinner"></view>
<text>加载更多...</text>
</view>
<view v-else-if="!hasMore && list.length > 0" class="no-more">- 到底啦 -</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getItemCards } from '../../api/appUser'
import { vibrateShort } from '@/utils/vibrate.js'
const list = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const currentTab = ref(0)
const page = ref(1)
const pageSize = 20
const hasMore = ref(true)
// ID
function getUserId() {
return uni.getStorageSync('user_id')
}
function getEmptyText() {
if (currentTab.value === 0) return '暂无可用道具卡'
if (currentTab.value === 1) return '暂无使用记录'
if (currentTab.value === 2) return '暂无过期道具卡'
return ''
}
//
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
//
function formatDateTime(t) {
if (!t) return ''
const d = new Date(t)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}
//
function getCardIcon(type) {
const t = String(type || '').toLowerCase()
if (t.includes('透视')) return '👁️'
if (t.includes('提示')) return '💡'
if (t.includes('重置')) return '🔄'
if (t.includes('翻倍')) return '✨'
if (t.includes('保护')) return '🛡️'
return '🃏'
}
// Tab
function switchTab(tab) {
if (currentTab.value === tab) return
vibrateShort()
currentTab.value = tab
list.value = []
page.value = 1
hasMore.value = true
fetchData()
}
//
async function onRefresh() {
isRefreshing.value = true
page.value = 1
hasMore.value = true
await fetchData(false)
isRefreshing.value = false
}
//
async function loadMore() {
if (loading.value || !hasMore.value) return
await fetchData(true)
}
//
async function fetchData(append = false) {
if (!checkAuth()) return
if (loading.value) return
loading.value = true
try {
const userId = getUserId()
// status: 1=unused, 2=used, 3=expired
const status = currentTab.value === 0 ? 1 : (currentTab.value === 1 ? 2 : 3)
const res = await getItemCards(userId, status, page.value, pageSize)
let items = Array.isArray(res) ? res : (res.list || res.data || [])
if (append) {
list.value = [...list.value, ...items]
} else {
list.value = items
}
if (items.length < pageSize) {
hasMore.value = false
} else {
page.value++
}
} catch (e) {
console.error('获取道具卡失败:', e)
hasMore.value = false
} finally {
loading.value = false
}
}
// 使
function onUseCard(item) {
vibrateShort()
// #ifdef MP-TOUTIAO
//
uni.switchTab({
url: '/pages/shop/index'
})
// #endif
// #ifndef MP-TOUTIAO
//
uni.switchTab({
url: '/pages/index/index'
})
// #endif
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;
text-transform: uppercase;
letter-spacing: 2rpx;
font-weight: 600;
}
/* Tab栏 */
.tab-bar {
@extend .glass-card;
display: flex;
margin: 0 $spacing-lg;
padding: 8rpx;
}
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
position: relative;
transition: all 0.3s;
}
.tab-text {
font-size: 28rpx;
color: $text-sub;
font-weight: 500;
}
.tab-item.active .tab-text {
color: $text-main;
font-weight: 700;
}
.tab-indicator {
position: absolute;
bottom: 4rpx;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
background: $brand-primary;
border-radius: 6rpx;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 280rpx);
padding: $spacing-lg;
position: relative;
z-index: 1;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
}
/* 道具卡列表 */
.item-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 票券式卡片 */
.item-ticket {
background: #fff;
border-radius: 16rpx;
display: flex;
overflow: hidden;
box-shadow: $shadow-sm;
position: relative;
animation: fadeInUp 0.5s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ticket-left {
width: 180rpx;
background: linear-gradient(135deg, #E6F7FF, #fff);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx;
position: relative;
}
.card-icon-wrap {
width: 90rpx;
height: 90rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 150, 250, 0.1);
}
.card-icon {
font-size: 48rpx;
}
.card-count-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
background: rgba(0, 150, 250, 0.1);
padding: 2rpx 10rpx;
border-radius: 100rpx;
}
.count-num {
font-size: 20rpx;
font-weight: 700;
color: #0096FA;
}
/* 分割线 */
.ticket-divider {
width: 30rpx;
position: relative;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.divider-notch {
width: 24rpx;
height: 24rpx;
background: $bg-page;
border-radius: 50%;
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 2;
}
.divider-notch.top {
top: -12rpx;
}
.divider-notch.bottom {
bottom: -12rpx;
}
.divider-dash {
width: 0;
height: 80%;
border-left: 2rpx dashed #eee;
}
.ticket-right {
flex: 1;
padding: 24rpx;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
position: relative;
}
.card-info {
display: flex;
flex-direction: column;
padding-right: 130rpx;
}
.card-name {
font-size: $font-md;
font-weight: 700;
margin-bottom: 8rpx;
color: $text-main;
}
.card-desc {
font-size: $font-xs;
color: $text-sub;
line-height: 1.4;
}
.card-desc {
font-size: $font-xs;
color: $text-sub;
line-height: 1.4;
}
.usage-info {
margin-top: 16rpx;
padding-top: 12rpx;
border-top: 1rpx dashed #eee;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-use-time {
font-size: 18rpx;
color: $text-tertiary;
}
.usage-detail {
font-size: 20rpx;
color: $text-sub;
display: flex;
flex-wrap: wrap;
align-items: center;
}
.detail-label {
color: $text-tertiary;
}
.detail-val {
font-weight: 500;
margin-left: 4rpx;
&.highlight {
color: $brand-primary;
font-weight: 700;
}
}
/* 按钮样式 */
.ticket-action-wrapper {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
z-index: 10;
}
.use-btn {
background: linear-gradient(135deg, #4facfe, #00f2fe);
padding: 12rpx 28rpx;
border-radius: 40rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 150, 250, 0.2);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
&:active {
transform: scale(0.92);
box-shadow: 0 2rpx 10rpx rgba(0, 150, 250, 0.15);
}
}
.btn-text {
color: #fff;
font-size: 24rpx;
font-weight: 700;
letter-spacing: 2rpx;
position: relative;
z-index: 2;
}
.btn-shine {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 100%
);
transform: skewX(-25deg);
animation: shine 3s infinite;
}
@keyframes shine {
0% { left: -100%; }
20%, 100% { left: 150%; }
}
.card-used-badge {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
background: #F5F5F5;
padding: 6rpx 16rpx;
border-radius: 8rpx;
}
.used-text {
font-size: 22rpx;
color: $text-tertiary;
}
/* 已使用状态 */
.item-ticket.used {
.ticket-left {
background: #f9f9f9;
}
.card-icon-wrap {
filter: grayscale(1);
opacity: 0.5;
}
.card-name {
color: $text-sub;
}
.card-desc {
color: $text-tertiary;
}
}
/* 已过期状态 */
.item-ticket.expired {
.ticket-left {
background: #fdfdfd;
}
.card-icon-wrap {
filter: grayscale(1) sepia(0.2);
opacity: 0.4;
}
.card-name {
color: $text-tertiary;
text-decoration: line-through;
}
.card-desc {
color: $text-tertiary;
}
.card-used-badge.expired {
background: #f0f0f0;
.used-text {
color: #999;
}
}
}
/* 加载动画 */
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 加载更多 */
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
color: $text-tertiary;
font-size: 24rpx;
gap: 12rpx;
}
.no-more {
text-align: center;
padding: 40rpx 0;
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
</style>

View File

@ -0,0 +1,920 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="error" class="error-state">
<view class="error-icon"></view>
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @tap="loadOrder">重试</button>
</view>
<!-- 订单内容 -->
<view v-else-if="order" class="content">
<!-- 状态头部背景 -->
<view class="status-header-bg" :class="getStatusClass(order)">
<view class="bg-circle c1"></view>
<view class="bg-circle c2"></view>
</view>
<!-- 状态卡片 -->
<view class="status-card">
<view class="status-content">
<view class="status-icon-wrap">
<text class="status-icon">{{ getStatusIcon(order) }}</text>
</view>
<view class="status-info">
<text class="status-title">{{ statusText(order) }}</text>
<text class="status-desc" v-if="order.status === 1">请在 15 分钟内完成支付</text>
<text class="status-desc" v-else-if="order.status === 3">订单已取消</text>
<text class="status-desc" v-else>感谢您的购买期待再次光临</text>
</view>
</view>
</view>
<!-- 奖品/商品列表 -->
<view class="section-card product-section">
<view class="section-header">
<text class="section-title">商品清单</text>
<text class="item-count"> {{ order.items ? order.items.length : (order.activity_name ? 1 : 0) }} </text>
</view>
<view class="order-items">
<!-- 常规商品列表 -->
<view v-for="(item, index) in order.items" :key="index" class="item-card">
<view class="item-image-wrap">
<image class="item-image" :src="getProductImage(item)" mode="aspectFill" />
<!-- 购买标识 -->
<view class="winner-tag" v-if="order.is_winner && (order.source_type === 2 || order.source_type === 3)">
<text class="tag-text">已开启</text>
</view>
<view class="level-tag" v-if="order.reward_level">
<text class="tag-text">{{ getLevelLabel(order.reward_level) }}</text>
</view>
</view>
<view class="item-info">
<text class="item-title">{{ item.title || '商品' }}</text>
<view class="item-tags" v-if="order.activity_name">
<text class="tag">{{ order.activity_name }}</text>
</view>
<view class="item-meta">
<view class="price-wrap">
<text class="currency" v-if="item.price > 0">¥</text>
<text class="price" v-if="item.price > 0">{{ formatPrice(item.price) }}</text>
<text class="price" v-else-if="order.points_amount > 0">{{ Math.floor(order.points_amount / (order.items && order.items.length || 1)) }}积分</text>
<text class="price" v-else>奖品</text>
</view>
<text class="item-quantity">x{{ item.quantity }}</text>
</view>
</view>
</view>
<!-- 活动信息当没有实物商品时显示 -->
<view v-if="(!order.items || order.items.length === 0) && order.activity_name" class="item-card">
<view class="item-image-wrap">
<image class="item-image" :src="defaultImage" mode="aspectFill" />
<view class="winner-tag" v-if="order.is_winner">
<text class="tag-text">已开启</text>
</view>
</view>
<view class="item-info">
<text class="item-title">{{ order.activity_name }}</text>
<view class="item-tags">
<text class="tag">参与记录</text>
<text class="tag" v-if="order.issue_number">{{ order.issue_number }}</text>
</view>
<view class="item-meta">
<view class="price-wrap">
<text class="currency" v-if="order.actual_amount > 0">¥</text>
<text class="price" v-if="order.actual_amount > 0">{{ formatPrice(order.actual_amount) }}</text>
<text class="price" v-else-if="order.points_amount > 0">{{ order.points_amount }}积分</text>
<text class="price" v-else>奖品</text>
</view>
<text class="item-quantity">x1</text>
</view>
</view>
</view>
</view>
</view>
<!-- 订单信息 -->
<view class="section-card info-section">
<view class="info-row">
<text class="label">订单编号</text>
<view class="value-wrap">
<text class="value mono">{{ order.order_no }}</text>
<view class="copy-btn" @tap="copyText(order.order_no)">复制</view>
</view>
</view>
<view class="info-row">
<text class="label">下单时间</text>
<text class="value">{{ formatTime(order.created_at) }}</text>
</view>
<view class="info-row" v-if="order.paid_at">
<text class="label">支付时间</text>
<text class="value">{{ formatTime(order.paid_at) }}</text>
</view>
<view class="info-row" v-if="order.cancelled_at">
<text class="label">取消时间</text>
<text class="value">{{ formatTime(order.cancelled_at) }}</text>
</view>
<view class="info-row">
<text class="label">订单来源</text>
<text class="value">{{ getSourceTypeText(order.source_type) }}</text>
</view>
</view>
<!-- 金额明细 -->
<view class="section-card amount-section">
<view class="info-row">
<text class="label">商品总额</text>
<text class="value" v-if="order.points_amount > 0">{{ order.points_amount }}积分</text>
<text class="value" v-else>¥{{ formatPrice(order.total_amount) }}</text>
</view>
<!-- 优惠券 -->
<view class="info-row" v-if="order.coupon_info">
<text class="label">优惠券</text>
<view class="value-wrap">
<text class="tag-small coupon">{{ order.coupon_info.name }}</text>
<text class="value discount">-¥{{ formatPrice(order.coupon_info.value) }}</text>
</view>
</view>
<!-- 道具卡 -->
<view class="info-row" v-if="order.item_card_info">
<text class="label">道具卡</text>
<view class="value-wrap">
<text class="tag-small card">{{ order.item_card_info.name }}</text>
<text class="value" v-if="order.item_card_info.effect_type === 1">双倍奖励</text>
<text class="value" v-else>已使用</text>
</view>
</view>
<view class="info-row" v-if="order.discount_amount && !order.coupon_info">
<text class="label">其他优惠</text>
<text class="value discount">-¥{{ formatPrice(order.discount_amount) }}</text>
</view>
<view class="divider"></view>
<view class="total-row">
<text class="total-label">{{ order.actual_amount > 0 ? '实付款' : '状态' }}</text>
<view class="total-price-wrap">
<text class="currency" v-if="order.actual_amount > 0">¥</text>
<text class="total-price" v-if="order.actual_amount > 0">{{ formatPrice(order.actual_amount) }}</text>
<text class="total-price" v-else-if="order.points_amount > 0">{{ order.points_amount }}积分</text>
<text class="total-price" v-else>无需支付</text>
</view>
</view>
</view>
<!-- 抽奖凭证有凭证数据时显示 -->
<view class="section-card proof-section" v-if="order.draw_receipts && order.draw_receipts.length > 0">
<view class="section-header">
<text class="section-title">抽奖凭证</text>
<text class="item-count help-btn" @tap="showProofHelp">?</text>
</view>
<view v-for="(receipt, idx) in order.draw_receipts" :key="idx" class="receipt-block">
<view class="info-row" v-if="receipt.algo_version">
<text class="label">算法版本</text>
<text class="value mono">{{ receipt.algo_version }}</text>
</view>
<view class="info-row" v-if="receipt.server_seed_hash">
<text class="label">服务端种子哈希</text>
<view class="value-wrap">
<text class="value mono seed-text">{{ receipt.server_seed_hash }}</text>
<view class="copy-btn" @tap="copyText(receipt.server_seed_hash)">复制</view>
</view>
</view>
<view class="info-row" v-if="receipt.server_sub_seed">
<text class="label">子种子</text>
<view class="value-wrap">
<text class="value mono seed-text">{{ receipt.server_sub_seed }}</text>
<view class="copy-btn" @tap="copyText(receipt.server_sub_seed)">复制</view>
</view>
</view>
<view class="info-row" v-if="receipt.client_seed">
<text class="label">客户端种子</text>
<text class="value mono">{{ receipt.client_seed }}</text>
</view>
<view class="info-row" v-if="receipt.draw_id">
<text class="label">抽奖ID</text>
<text class="value mono">{{ receipt.draw_id }}</text>
</view>
<view class="info-row" v-if="receipt.timestamp">
<text class="label">时间戳</text>
<text class="value mono">{{ receipt.timestamp }}</text>
</view>
<view class="info-row" v-if="receipt.round_id">
<text class="label">期次ID</text>
<text class="value">{{ receipt.round_id }}</text>
</view>
</view>
<view class="proof-notice">
<text class="notice-icon">🔒</text>
<text class="notice-text">以上数据可用于验证抽奖结果的公正性</text>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="footer-actions safe-area-bottom" v-if="order && order.status === 1">
<view class="action-btn secondary" @tap="handleCancel">取消订单</view>
<view class="action-btn primary" @tap="handlePay">立即支付</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getOrderDetail, cancelOrder, createWechatOrder } from '../../api/appUser'
import { levelToAlpha } from '@/utils/activity'
const orderId = ref('')
const order = ref(null)
const loading = ref(true)
const error = ref('')
const defaultImage = 'https://keaiya-1259195914.cos.ap-shanghai.myqcloud.com/images/default-product.png'
onLoad((options) => {
if (options.id) {
orderId.value = options.id
loadOrder()
} else {
error.value = '参数错误'
loading.value = false
}
})
async function loadOrder() {
loading.value = true
error.value = ''
try {
const res = await getOrderDetail(orderId.value)
order.value = res
} catch (e) {
error.value = e.message || '获取订单详情失败'
} finally {
loading.value = false
}
}
function handleCancel() {
uni.showModal({
title: '确认取消',
content: '确定要取消这个订单吗?',
confirmColor: '#FF6B00',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '取消中...' })
try {
await cancelOrder(orderId.value, '用户主动取消')
uni.hideLoading()
uni.showToast({ title: '订单已取消', icon: 'success' })
loadOrder()
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '取消失败', icon: 'none' })
}
}
}
})
}
function handlePay() {
const openid = uni.getStorageSync('openid')
if (!openid) {
uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' })
return
}
const ord = order.value
if (!ord || !ord.order_no) return
uni.showLoading({ title: '拉起支付...' })
createWechatOrder({ openid, order_no: ord.order_no })
.then((payRes) => new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
}))
.then(async () => {
uni.hideLoading()
uni.showToast({ title: '支付成功', icon: 'success' })
//
navigateToGame(ord)
})
.catch((e) => {
uni.hideLoading()
if (e?.errMsg && String(e.errMsg).includes('cancel')) {
uni.showToast({ title: '支付已取消', icon: 'none' })
return
}
uni.showToast({ title: e?.message || '支付失败', icon: 'none' })
})
}
function navigateToGame(ord) {
const playType = ord.play_type
const activityId = ord.activity_id
if (!activityId) {
// ID
uni.navigateBack()
return
}
let url = ''
if (playType === 'match') {
url = `/pages-activity/activity/duiduipeng/index?activity_id=${activityId}`
} else if (playType === 'ichiban') {
url = `/pages-activity/activity/yifanshang/index?activity_id=${activityId}`
} else if (playType === 'infinity') {
url = `/pages-activity/activity/wuxianshang/index?activity_id=${activityId}`
}
if (url) {
uni.redirectTo({ url })
} else {
uni.navigateBack()
}
}
function copyText(text) {
if (!text) return
uni.setClipboardData({
data: String(text),
success: () => {
uni.showToast({ title: '已复制', icon: 'none' })
}
})
}
function formatPrice(price) {
if (price === undefined || price === null) return '0.00'
return (Number(price) / 100).toFixed(2)
}
function formatTime(t) {
if (!t) return ''
const date = new Date(t)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
const ss = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
}
function getProductImage(item) {
if (item.product_images) {
try {
const parsed = JSON.parse(item.product_images)
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed[0]
}
} catch (e) {
if (typeof item.product_images === 'string' && item.product_images.startsWith('http')) {
return item.product_images
}
}
}
return defaultImage
}
function statusText(item) {
const status = item.status
if (status === 1) return '待付款'
if (status === 2) return '已完成'
if (status === 3) return '已取消'
return '进行中'
}
function getStatusClass(item) {
const status = item.status
if (status === 1) return 'status-pending'
if (status === 2) return 'status-completed'
if (status === 3) return 'status-cancelled'
return ''
}
function getStatusIcon(item) {
const status = item.status
if (status === 1) return '🕒'
if (status === 2) return '🎉'
if (status === 3) return '🚫'
return '📦'
}
function getSourceTypeText(type) {
if (type === 1) return '商城订单'
if (type === 2 || type === 3) {
// 使
if (order.value && order.value.category_name) return order.value.category_name
if (order.value && order.value.activity_name) return order.value.activity_name
const playType = order.value && order.value.play_type
if (playType === 'match') return '对对碰'
if (playType === 'ichiban') return '一番赏'
if (type === 2) return '抽奖订单'
return '发奖记录'
}
return '其他'
}
function getLevelLabel(level) {
return levelToAlpha(level)
}
function showProofHelp() {
uni.showModal({
title: '抽奖凭证说明',
content: '该凭证包含本次抽奖的随机种子和参数,可用于验证抽奖的公平性。您可以复制相关数据,自行进行核验。',
showCancel: false
})
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
padding-bottom: calc(140rpx + env(safe-area-inset-top) + env(safe-area-inset-bottom));
position: relative;
overflow: hidden;
}
/* 背景装饰 - 漂浮光球 (与各主要页面统一) */
.bg-decoration {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
/* 状态头部背景 */
.status-header-bg {
height: 150rpx;
position: relative;
overflow: hidden;
border-radius: 0 0 40rpx 40rpx;
z-index: 1;
&.status-pending { background: linear-gradient(135deg, #FF9F43 0%, #FF6B6B 100%); }
&.status-completed { background: linear-gradient(135deg, #2ECC71 0%, #27AE60 100%); }
&.status-cancelled { background: linear-gradient(135deg, #95A5A6 0%, #7F8C8D 100%); }
.bg-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
&.c1 { width: 300rpx; height: 300rpx; top: -100rpx; right: -50rpx; }
&.c2 { width: 200rpx; height: 200rpx; bottom: 50rpx; left: -50rpx; }
}
}
/* 状态卡片 */
.status-card {
margin: -60rpx $spacing-lg 0;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20rpx);
border-radius: $radius-xl;
padding: $spacing-xl;
box-shadow: $shadow-card;
position: relative;
z-index: 10;
border: 1px solid rgba(255, 255, 255, 0.6);
.status-content {
display: flex;
align-items: center;
gap: $spacing-lg;
}
.status-icon-wrap {
width: 88rpx;
height: 88rpx;
background: $bg-secondary;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.status-icon {
font-size: 44rpx;
}
.status-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.status-title {
font-size: 36rpx;
font-weight: 800;
color: $text-main;
}
.status-desc {
font-size: $font-sm;
color: $text-sub;
}
}
/* 通用卡片样式 */
.section-card {
margin: $spacing-lg;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20rpx);
border-radius: $radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-sm;
animation: slideUp 0.4s ease-out;
border: 1px solid rgba(255, 255, 255, 0.6);
position: relative;
z-index: 5;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-sm;
border-bottom: 2rpx dashed $border-color-light;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: $text-main;
position: relative;
padding-left: 20rpx;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 24rpx;
background: $brand-primary;
border-radius: 4rpx;
}
}
.item-count {
font-size: $font-sm;
color: $text-sub;
}
/* 商品列表 */
.item-card {
display: flex;
gap: $spacing-md;
margin-bottom: $spacing-lg;
&:last-child { margin-bottom: 0; }
}
.item-image-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: $radius-md;
overflow: hidden;
background: $bg-secondary;
}
.item-image {
width: 100%;
height: 100%;
}
.winner-tag {
position: absolute;
top: 0;
left: 0;
background: $gradient-gold;
padding: 4rpx 12rpx;
border-radius: 0 0 $radius-md 0;
z-index: 1;
.tag-text {
color: #fff;
font-size: 18rpx;
font-weight: 700;
}
}
.level-tag {
position: absolute;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
padding: 2rpx 10rpx;
border-radius: $radius-sm 0 0 0;
.tag-text {
color: #fff;
font-size: 18rpx;
font-weight: 600;
}
}
.item-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 4rpx 0;
}
.item-title {
font-size: 28rpx;
color: $text-main;
font-weight: 600;
line-height: 1.4;
@include text-ellipsis(2);
}
.item-tags {
margin-top: 8rpx;
display: flex;
.tag {
font-size: 20rpx;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
padding: 2rpx 10rpx;
border-radius: 6rpx;
}
}
.item-meta {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: auto;
}
.price-wrap {
display: flex;
align-items: baseline;
color: $text-main;
.currency { font-size: 24rpx; font-weight: 600; }
.price { font-size: 32rpx; font-weight: 700; font-family: 'DIN Alternate', sans-serif; }
}
.item-quantity {
font-size: 24rpx;
color: $text-sub;
}
/* 信息行 */
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
.label {
font-size: 26rpx;
color: $text-sub;
}
.value {
font-size: 26rpx;
color: $text-main;
&.mono { font-family: monospace; }
&.discount { color: $uni-color-error; font-weight: 600; }
}
.value-wrap {
display: flex;
align-items: center;
gap: 12rpx;
}
.copy-btn {
font-size: 20rpx;
color: $text-sub;
border: 1rpx solid $border-color;
padding: 2rpx 12rpx;
border-radius: 20rpx;
&:active {
opacity: 0.6;
background: $bg-secondary;
}
}
.tag-small {
font-size: 20rpx;
padding: 2rpx 8rpx;
border-radius: 6rpx;
&.coupon {
color: #FF6B6B;
background: rgba(255, 107, 107, 0.1);
border: 1rpx solid rgba(255, 107, 107, 0.2);
}
&.card {
color: #6C5CE7;
background: rgba(108, 92, 231, 0.1);
border: 1rpx solid rgba(108, 92, 231, 0.2);
}
}
}
.divider {
height: 1rpx;
background: $border-color-light;
margin: 20rpx 0;
}
.total-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16rpx;
padding-top: 10rpx;
.total-label {
font-size: 28rpx;
font-weight: 600;
color: $text-main;
}
.total-price-wrap {
color: $brand-primary;
display: flex;
align-items: baseline;
.currency { font-size: 28rpx; font-weight: 600; }
.total-price { font-size: 40rpx; font-weight: 800; font-family: 'DIN Alternate', sans-serif; }
}
}
/* 底部操作栏 */
.footer-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20rpx);
padding: 24rpx 32rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
display: flex;
justify-content: flex-end;
gap: 24rpx;
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.06);
z-index: 100;
}
.action-btn {
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 48rpx;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 600;
transition: all 0.2s;
&:active { transform: scale(0.96); }
&.secondary {
background: #fff;
color: $text-main;
border: 2rpx solid $border-color;
}
&.primary {
background: $gradient-brand;
color: #fff;
box-shadow: $shadow-warm;
}
}
/* Loading & Error */
.loading-state, .error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80vh;
.loading-text, .error-text {
margin-top: 24rpx;
color: $text-sub;
font-size: 28rpx;
}
}
.loading-spinner {
width: 64rpx;
height: 64rpx;
border: 6rpx solid rgba($brand-primary, 0.2);
border-top-color: $brand-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.retry-btn {
margin-top: 32rpx;
background: $brand-primary;
color: #fff;
font-size: 28rpx;
padding: 12rpx 48rpx;
border-radius: 32rpx;
}
/* 抽奖凭证区 */
.proof-section {
.seed-text {
font-size: 22rpx;
word-break: break-all;
max-width: 360rpx;
@include text-ellipsis(1);
}
.proof-notice {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-top: $spacing-md;
padding: $spacing-sm $spacing-md;
background: rgba($brand-primary, 0.06);
border-radius: $radius-md;
.notice-icon {
font-size: 24rpx;
}
.notice-text {
font-size: 22rpx;
color: $text-sub;
}
}
}
</style>

View File

@ -1,7 +1,9 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<!-- 顶部 Tab -->
<view class="tabs">
<view class="tabs glass-card">
<view
class="tab-item"
:class="{ active: currentTab === 'pending' }"
@ -72,7 +74,7 @@
mode="aspectFill"
/>
<view class="image-overlay" v-if="item.is_winner">
<text class="winner-badge">🎉 中奖</text>
<text class="winner-badge">🎉 已开启</text>
</view>
</view>
@ -82,6 +84,8 @@
<view class="product-meta">
<text class="meta-item" v-if="item.activity_name">{{ item.activity_name }}</text>
<text class="meta-item" v-if="item.issue_number">{{ item.issue_number }}</text>
<text class="meta-item coupon-tag" v-if="item.coupon_info">: {{ item.coupon_info.name }}</text>
<text class="meta-item card-tag" v-if="item.item_card_info">: {{ item.item_card_info.name }}</text>
</view>
<text class="order-time">{{ formatTime(item.created_at) }}</text>
</view>
@ -94,14 +98,14 @@
<text class="no-value">{{ item.order_no }}</text>
</view>
<view class="order-amount">
<text class="amount-label">实付</text>
<text class="amount-value">{{ formatAmount(item.actual_amount || item.total_amount) }}</text>
</view>
<text class="amount-label" v-if="shouldShowAmountLabel(item)">实付</text>
<text class="amount-value">{{ getAmountText(item) }}</text>
</view>
</view>
<!-- 快捷操作 -->
<view class="order-actions" v-if="currentTab === 'pending'">
<button class="action-btn secondary" @tap.stop="cancelOrder(item)">取消订单</button>
<button class="action-btn secondary" @tap.stop="doCancelOrder(item)">取消订单</button>
<button class="action-btn primary" @tap.stop="payOrder(item)">立即支付</button>
</view>
</view>
@ -124,7 +128,8 @@
<script setup>
import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import { getOrders, cancelOrder as cancelOrderApi } from '../../api/appUser'
import { getOrders, cancelOrder as cancelOrderApi, createWechatOrder } from '../../api/appUser'
import { vibrateShort } from '@/utils/vibrate.js'
const currentTab = ref('pending')
const orders = ref([])
@ -159,7 +164,10 @@ function formatTime(t) {
return `${m}-${day} ${hh}:${mm}`
}
function formatAmount(a) {
function formatAmount(a, item) {
if (item && item.points_amount > 0) {
return `${item.points_amount}积分`
}
if (a === undefined || a === null) return '¥0.00'
const n = Number(a)
if (Number.isNaN(n)) return '¥0.00'
@ -167,20 +175,46 @@ function formatAmount(a) {
return `¥${yuan.toFixed(2)}`
}
function shouldShowAmountLabel(item) {
const amount = item.actual_amount || item.total_amount
return amount > 0
}
function getAmountText(item) {
if (item.points_amount > 0) return formatAmount(0, item)
const amount = item.actual_amount || item.total_amount
if (amount > 0) return formatAmount(amount)
// 0
if (item.source_type === 3 || item.source_type === 2) {
return '奖品'
}
return '免费'
}
function getOrderTitle(item) {
// 使 remark
if (item.remark && !item.remark.startsWith('lottery:')) {
return item.remark
// 1. 使 items
if (item.items && item.items.length > 0 && item.items[0].title) {
return item.items[0].title
}
// 使 items
if (item.items && item.items.length > 0) {
return item.items[0].title || '商品'
}
// 使
// 2. 使
if (item.activity_name) {
return item.activity_name
}
return item.title || item.subject || '订单'
// 3. remark
if (item.remark) {
// lottery:xxx, matching_game:xxx
if (!item.remark.startsWith('lottery:') &&
!item.remark.startsWith('matching_game:') &&
!item.remark.includes(':issue:')) {
return item.remark
}
}
// 4.
return item.title || item.subject || '盲盒订单'
}
function getProductImage(item) {
@ -205,14 +239,28 @@ function getProductImage(item) {
function getTypeIcon(item) {
const sourceType = item.source_type
if (sourceType === 2) return '🎰' //
if (sourceType === 2 || sourceType === 3) {
//
const playType = item.play_type
if (playType === 'match') return '🎮' //
if (playType === 'ichiban') return '🎰' //
if (sourceType === 2) return '🎲' //
}
if (sourceType === 1) return '🛒' //
return '📦'
}
function getTypeName(item) {
const sourceType = item.source_type
if (sourceType === 2) return '一番赏'
if (sourceType === 2 || sourceType === 3) {
// 使
if (item.category_name) return item.category_name
if (item.activity_name) return item.activity_name
const playType = item.play_type
if (playType === 'match') return '对对碰'
if (playType === 'ichiban') return '一番赏'
if (sourceType === 2) return '抽奖'
}
if (sourceType === 1) return '商城'
return '订单'
}
@ -242,26 +290,30 @@ function getStatusClass(item) {
function switchTab(tab) {
if (currentTab.value === tab) return
vibrateShort()
currentTab.value = tab
fetchOrders(false)
}
function apiStatus() {
return currentTab.value === 'pending' ? 'pending' : 'completed'
// 1: , 2:
return currentTab.value === 'pending' ? 1 : 2
}
// source_type=3
function filterOrders(items) {
if (!Array.isArray(items)) return []
return items.filter(item => item.source_type !== 3)
// source_type=3 source_type=3
return items
}
async function fetchOrders(append) {
const user_id = uni.getStorageSync('user_id')
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
// 使
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
if (!user_id || !token || !phoneBound) {
if (!user_id || !token || !hasPhoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
@ -349,12 +401,18 @@ async function fetchAllOrders() {
function goOrderDetail(item) {
//
uni.navigateTo({
url: `/pages/orders/detail?id=${item.id}&order_no=${item.order_no}`
url: `/pages-user/orders/detail?id=${item.id}&order_no=${item.order_no}`
})
}
function goShopping() {
// #ifdef MP-TOUTIAO
//
uni.switchTab({ url: '/pages/shop/index' })
// #endif
// #ifndef MP-TOUTIAO
uni.switchTab({ url: '/pages/index/index' })
// #endif
}
async function doCancelOrder(item) {
@ -379,9 +437,65 @@ async function doCancelOrder(item) {
})
}
function payOrder(item) {
// TODO:
uni.showToast({ title: '支付功能开发中', icon: 'none' })
async function payOrder(item) {
const openid = uni.getStorageSync('openid')
if (!openid) {
uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' })
return
}
if (!item || !item.order_no) return
uni.showLoading({ title: '拉起支付...' })
try {
const payRes = await createWechatOrder({ openid, order_no: item.order_no })
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
uni.hideLoading()
uni.showToast({ title: '支付成功', icon: 'success' })
navigateToGame(item)
} catch (e) {
uni.hideLoading()
if (e?.errMsg && String(e.errMsg).includes('cancel')) {
uni.showToast({ title: '支付已取消', icon: 'none' })
return
}
uni.showToast({ title: e?.message || '支付失败', icon: 'none' })
}
}
function navigateToGame(item) {
const playType = item.play_type
const activityId = item.activity_id
if (!activityId) {
fetchOrders(false) //
return
}
let url = ''
if (playType === 'match') {
url = `/pages-activity/activity/duiduipeng/index?activity_id=${activityId}`
} else if (playType === 'ichiban') {
url = `/pages-activity/activity/yifanshang/index?activity_id=${activityId}`
} else if (playType === 'infinity') {
url = `/pages-activity/activity/wuxianshang/index?activity_id=${activityId}`
}
if (url) {
uni.navigateTo({ url })
} else {
fetchOrders(false)
}
}
onLoad((opts) => {
@ -397,30 +511,34 @@ onReachBottom(() => {
<style lang="scss" scoped>
/* ============================================
订单页面 - 高级设计重构
柯大鸭潮玩 - 订单页面
采用暖橙色调的订单列表设计
============================================ */
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
padding-bottom: calc(40rpx + env(safe-area-inset-top) + env(safe-area-inset-bottom));
overflow: hidden;
}
/* 顶部 Tab - 与货柜页面保持一致 */
/* 顶部 Tab */
.tabs {
@extend .glass-card;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 88rpx;
background: rgba($bg-card, 0.95);
backdrop-filter: blur(20rpx);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
border-radius: 0;
border-top: none;
border-left: none;
border-right: none;
}
.tab-item {
@ -570,16 +688,19 @@ onReachBottom(() => {
/* 订单卡片 */
.order-card {
background: $bg-card;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10rpx);
border-radius: $radius-xl;
overflow: hidden;
box-shadow: $shadow-card;
box-shadow: $shadow-sm;
animation: fadeInUp 0.4s ease-out backwards;
animation-delay: var(--delay, 0s);
transition: all 0.2s;
border: 1px solid rgba(255, 255, 255, 0.6);
&:active {
transform: scale(0.98);
box-shadow: none;
}
}
@ -698,6 +819,14 @@ onReachBottom(() => {
background: $bg-secondary;
padding: 4rpx 12rpx;
border-radius: $radius-sm;
.coupon-tag {
color: #FF6B6B;
background: rgba(255, 107, 107, 0.1);
}
.card-tag {
color: #6C5CE7;
background: rgba(108, 92, 231, 0.1);
}
}
.order-time {
font-size: $font-xs;

View File

@ -1,7 +1,7 @@
<template>
<view class="wrap">
<!-- 顶部装饰背景 -->
<view class="page-bg-decoration"></view>
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">积分明细</view>
@ -26,14 +26,14 @@
class="record-item"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<view class="record-icon" :class="{ 'is-add': (item.change || item.amount || 0) > 0 }">
{{ (item.change || item.amount || 0) > 0 ? '↓' : '↑' }}
<view class="record-icon" :class="{ 'is-add': (item.points || 0) > 0 }">
{{ (item.points || 0) > 0 ? '↓' : '↑' }}
</view>
<view class="record-content">
<view class="record-main">
<view class="record-title">{{ item.title || item.reason || '积分变更' }}</view>
<view class="record-amount" :class="{ inc: (item.change || item.amount || 0) > 0, dec: (item.change || item.amount || 0) < 0 }">
{{ (item.change ?? item.amount ?? 0) > 0 ? '+' : '' }}{{ item.change ?? item.amount ?? 0 }}
<view class="record-title">{{ getActionText(item.action) || item.title || item.reason || '积分变更' }}</view>
<view class="record-amount" :class="{ inc: (item.points || 0) > 0, dec: (item.points || 0) < 0 }">
{{ (item.points ?? 0) > 0 ? '+' : '' }}{{ formatPoints(item.points) }}
</view>
</view>
<view class="record-footer">
@ -65,6 +65,11 @@ const error = ref('')
const page = ref(1)
const pageSize = ref(20)
const hasMore = ref(true)
function formatPoints(v) {
const n = Number(v) || 0
if (n === 0) return '0'
return n.toString()
}
function formatTime(t) {
if (!t) return ''
@ -77,11 +82,32 @@ function formatTime(t) {
return `${y}-${m}-${day} ${hh}:${mm}`
}
function getActionText(action) {
const map = {
'signin': '每日签到',
'register': '注册赠送',
'invite_reward': '邀请奖励',
'order_deduct': '下单抵扣',
'consume_order': '下单消费',
'refund_restore': '退款返还',
'refund_points': '积分退回',
'refund_amount': '金额退款奖励',
'manual_add': '管理手动增加',
'manual': '系统调整',
'redeem_coupon': '兑换优惠券',
'redeem_product': '兑换商品',
'redeem_reward': '奖品兑换积分',
'redeem_item_card': '兑换道具卡'
}
return map[action] || ''
}
async function fetchRecords(append = false) {
const user_id = uni.getStorageSync('user_id')
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!user_id || !token || !phoneBound) {
// 使
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
if (!user_id || !token || !hasPhoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
@ -106,7 +132,7 @@ async function fetchRecords(append = false) {
error.value = ''
try {
const list = await getPointsRecords(user_id, page.value, pageSize.value)
const items = Array.isArray(list) ? list : (list && list.items) || []
const items = Array.isArray(list) ? list : (list && (list.list || list.items)) || []
const total = (list && list.total) || 0
if (append) {
records.value = records.value.concat(items)
@ -143,33 +169,27 @@ onReachBottom(() => {
min-height: 100vh;
background-color: $bg-page;
position: relative;
overflow-x: hidden;
overflow: hidden;
}
.page-bg-decoration {
position: absolute;
top: -200rpx;
right: -200rpx;
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15), transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
/* 背景装饰 - 漂浮光球 (与个人中心统一) */
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;

View File

@ -0,0 +1,196 @@
<template>
<view class="settings-container">
<!-- 自定义 tabBar -->
<!-- #ifdef MP-TOUTIAO -->
<customTabBarToutiao />
<!-- #endif -->
<!-- #ifndef MP-TOUTIAO -->
<customTabBar />
<!-- #endif -->
<!-- 顶部导航栏 -->
<!-- #ifndef MP-TOUTIAO -->
<view class="navbar">
<view class="navbar-content">
<text class="navbar-title">设置</text>
</view>
</view>
<!-- #endif -->
<!-- 设置内容区域 -->
<view class="settings-content">
<!-- 退出登录按钮 -->
<view class="logout-section">
<view class="logout-btn" @click="handleLogout">
<view class="logout-icon">
<image class="logout-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNGRjQwNDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05IDIxSDVhMiAyIDAgMCAxLTItMnYtNWEyIDIgMCAwIDEgMi0yaDRtMCAwdjZtMC0wdjZtMC02aDZhMiAyIDAgMCAxIDIgMnY4YTIgMiAwIDAgMS0yIDJINyIgLz48cGF0aCBkPSJNOCAxM2w1LTU1IDU1LTUiIC8+PC9zdmc+" mode="aspectFit"></image>
</view>
<text class="logout-text">退出当前账号</text>
</view>
</view>
</view>
</view>
</template>
<script>
// #ifdef MP-TOUTIAO
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
// #endif
// #ifndef MP-TOUTIAO
import customTabBar from '@/components/app-tab-bar.vue'
// #endif
export default {
components: {
// #ifdef MP-TOUTTAO
customTabBarToutiao
// #endif
// #ifndef MP-TOUTIAO
customTabBar
// #endif
},
data() {
return {}
},
methods: {
handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出当前账号吗?退出后将清空本地缓存。',
confirmText: '确定退出',
confirmColor: '#FF6B00',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
this.logout()
}
}
})
},
logout() {
try {
//
uni.clearStorageSync()
//
uni.showToast({
title: '已退出登录',
icon: 'success',
duration: 1500
})
//
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/index'
})
}, 1500)
} catch (e) {
console.error('退出登录失败:', e)
uni.showToast({
title: '退出失败,请重试',
icon: 'none'
})
}
}
}
}
</script>
<style lang="scss" scoped>
.settings-container {
min-height: 100vh;
background-color: $bg-page;
padding-bottom: calc(env(safe-area-inset-bottom) + 120rpx);
}
/* 导航栏 */
/* #ifndef MP-TOUTIAO */
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(255,255,255,0.95));
backdrop-filter: blur(20rpx);
border-bottom: 1px solid rgba(0,0,0,0.05);
padding-top: env(safe-area-inset-top);
}
.navbar-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.navbar-title {
font-size: 32rpx;
font-weight: 800;
color: $text-main;
}
/* #endif */
/* 设置内容区 */
/* #ifdef MP-TOUTIAO */
.settings-content {
padding-top: $spacing-lg;
padding-left: $spacing-lg;
padding-right: $spacing-lg;
}
/* #endif */
/* #ifndef MP-TOUTIAO */
.settings-content {
padding-top: calc(env(safe-area-inset-top) + 88rpx + $spacing-lg);
padding-left: $spacing-lg;
padding-right: $spacing-lg;
}
/* #endif */
/* 退出登录区域 */
.logout-section {
background: $bg-card;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-card;
}
.logout-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
cursor: pointer;
transition: all 0.2s;
&:active {
background: $uni-bg-color-hover;
transform: scale(0.98);
}
}
.logout-icon {
width: 48rpx;
height: 48rpx;
margin-right: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.logout-icon-img {
width: 48rpx;
height: 48rpx;
}
.logout-text {
font-size: $font-lg;
font-weight: 700;
color: #FF4040;
}
</style>

857
pages-user/tasks/index.vue Normal file
View File

@ -0,0 +1,857 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">任务中心</view>
<view class="page-subtitle">Task Center</view>
</view>
<!-- 进度统计卡片 - 毛玻璃风格 -->
<view class="progress-card glass-card">
<view class="progress-header">
<text class="progress-title">📊 我的任务进度</text>
</view>
<view class="progress-stats">
<view class="stat-item">
<text class="stat-value highlight">{{ userProgress.orderCount || 0 }}</text>
<text class="stat-label">累计订单</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value highlight">{{ userProgress.inviteCount || 0 }}</text>
<text class="stat-label">邀请人数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<view class="stat-value first-order-check" :class="{ done: userProgress.firstOrder }">
{{ userProgress.firstOrder ? '✓' : '—' }}
</view>
<text class="stat-label">首单完成</text>
</view>
</view>
</view>
<!-- 任务列表 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<!-- 加载状态 -->
<view v-if="loading && tasks.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="tasks.length === 0" class="empty-state">
<text class="empty-icon">📝</text>
<text class="empty-text">暂无可用任务</text>
<text class="empty-hint">敬请期待更多精彩活动</text>
</view>
<!-- 任务卡片列表 -->
<view v-else class="task-list">
<view
v-for="(task, index) in tasks"
:key="task.id"
class="task-card"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<!-- 任务头部 -->
<view class="task-header" @click="toggleTask(task.id)">
<view class="task-info">
<text class="task-icon">{{ getTaskIcon(task) }}</text>
<view class="task-meta">
<text class="task-name">{{ task.name }}</text>
<text class="task-desc">{{ task.description }}</text>
</view>
</view>
<view class="task-status-wrap">
<view class="task-status" :class="getTaskStatusClass(task)">
{{ getTaskStatusText(task) }}
</view>
<text class="expand-arrow" :class="{ expanded: expandedTasks[task.id] }"></text>
</view>
</view>
<!-- 档位列表 (可展开) -->
<view class="tier-list" v-if="expandedTasks[task.id] && task.tiers && task.tiers.length > 0">
<view
v-for="tier in task.tiers"
:key="tier.id"
class="tier-item"
:class="{ 'tier-claimed': isTierClaimed(task.id, tier.id), 'tier-claimable': isTierClaimable(task, tier) }"
>
<view class="tier-left">
<view class="tier-condition">
<text class="tier-badge">{{ getTierBadge(tier) }}</text>
<text class="tier-text">{{ getTierConditionText(tier) }}</text>
</view>
<view class="tier-reward">
<text class="reward-icon">🎁</text>
<text class="reward-text">{{ getTierRewardText(task, tier) }}</text>
</view>
</view>
<view class="tier-right">
<!-- 已领取 -->
<view v-if="isTierClaimed(task.id, tier.id)" class="tier-btn claimed">
<text>已领取</text>
</view>
<!-- 可领取 -->
<view v-else-if="isTierClaimable(task, tier)" class="tier-btn claimable" @click="claimReward(task, tier)">
<text>{{ claiming[`${task.id}_${tier.id}`] ? '领取中...' : '领取' }}</text>
</view>
<!-- 进度中 -->
<view v-else class="tier-progress">
<text class="progress-text">{{ getTierProgressText(task, tier) }}</text>
</view>
</view>
</view>
</view>
<!-- 无档位提示 -->
<view class="no-tier-hint" v-if="expandedTasks[task.id] && (!task.tiers || task.tiers.length === 0)">
<text>暂无可领取档位</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getTasks, getTaskProgress, claimTaskReward } from '../../api/appUser'
import { vibrateShort } from '@/utils/vibrate.js'
const tasks = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const expandedTasks = reactive({})
const claiming = reactive({})
// ( - )
const userProgress = reactive({
orderCount: 0,
orderAmount: 0,
inviteCount: 0,
firstOrder: false,
claimedTiers: {} // { taskId: [tierId1, tierId2] }
})
// BUG
const taskProgress = reactive({}) // { taskId: { orderCount, orderAmount, inviteCount, firstOrder } }
// ID
function getUserId() {
return uni.getStorageSync('user_id')
}
//
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
//
function getTaskIcon(task) {
const name = (task.name || '').toLowerCase()
if (name.includes('首单') || name.includes('first')) return '🎁'
if (name.includes('订单') || name.includes('order')) return '📦'
if (name.includes('邀请') || name.includes('invite')) return '👥'
if (name.includes('签到') || name.includes('check')) return '📅'
if (name.includes('分享') || name.includes('share')) return '📣'
return '⭐'
}
//
function getTaskStatusClass(task) {
const progress = userProgress.claimedTiers[task.id] || []
const allTiers = task.tiers || []
if (allTiers.length === 0) return 'status-waiting'
//
const allClaimed = allTiers.every(t => progress.includes(t.id))
if (allClaimed) return 'status-done'
//
if (allTiers.some(t => isTierClaimable(task, t) && !progress.includes(t.id))) {
return 'status-claimable'
}
return 'status-progress'
}
//
function getTaskStatusText(task) {
const progress = userProgress.claimedTiers[task.id] || []
const allTiers = task.tiers || []
if (allTiers.length === 0) return '暂无档位'
const allClaimed = allTiers.every(t => progress.includes(t.id))
if (allClaimed) return '已完成'
if (allTiers.some(t => isTierClaimable(task, t) && !progress.includes(t.id))) {
return '可领取'
}
return '进行中'
}
// /
function toggleTask(taskId) {
expandedTasks[taskId] = !expandedTasks[taskId]
}
//
function getTierBadge(tier) {
const metric = tier.metric || ''
if (metric === 'first_order') return '首'
if (metric === 'order_count') return `${tier.threshold}`
if (metric === 'order_amount') return `¥${tier.threshold / 100}`
if (metric === 'invite_count') return `${tier.threshold}`
return tier.threshold || ''
}
//
function getTierConditionText(tier) {
const metric = tier.metric || ''
if (metric === 'first_order') return '完成首笔订单'
if (metric === 'order_count') return `累计下单 ${tier.threshold}`
if (metric === 'order_amount') return `累计消费 ¥${tier.threshold / 100}`
if (metric === 'invite_count') return `邀请 ${tier.threshold} 位好友`
return `达成 ${tier.threshold}`
}
//
function getTierRewardText(task, tier) {
const rewards = (task.rewards || []).filter(r => r.tier_id === tier.id)
if (rewards.length === 0) return '神秘奖励'
const texts = rewards.map(r => {
const type = r.reward_type || ''
const name = r.reward_name || ''
const payload = r.reward_payload || {}
const qty = r.quantity || 1
// 使 reward_name
if (name) {
if (type === 'points') {
const points = payload.points || qty
return points > 1 ? `${points}${name}` : name
}
if (type === 'coupon') {
const value = payload.value || payload.amount
return value ? `${name}${value / 100})` : name
}
return qty > 1 ? `${name}×${qty}` : name
}
// 退 payload
if (type === 'points') {
const value = payload.points || payload.value || payload.amount || qty
return `${value}积分`
}
if (type === 'coupon') {
const value = payload.value || payload.amount
return value ? `¥${value / 100}优惠券` : '优惠券'
}
if (type === 'item_card') {
return payload.name || '道具卡'
}
if (type === 'title') {
return payload.name || '专属称号'
}
if (type === 'game_ticket') {
return payload.game_code ? `${payload.amount || 1}张抽奖券` : '抽奖券'
}
return '奖励'
})
return texts.join(' + ')
}
//
function isTierClaimed(taskId, tierId) {
const claimed = userProgress.claimedTiers[taskId] || []
return claimed.includes(tierId)
}
// - BUG使
function isTierClaimable(task, tier) {
const metric = tier.metric || ''
const threshold = tier.threshold || 0
const operator = tier.operator || '>='
//
const progress = taskProgress[task.id] || {}
let current = 0
if (metric === 'first_order') {
return progress.firstOrder || false
} else if (metric === 'order_count') {
current = progress.orderCount || 0
} else if (metric === 'order_amount') {
current = progress.orderAmount || 0
} else if (metric === 'invite_count') {
current = progress.inviteCount || 0
}
if (operator === '>=') return current >= threshold
if (operator === '==') return current === threshold
if (operator === '>') return current > threshold
return current >= threshold
}
// - BUG使
function getTierProgressText(task, tier) {
const metric = tier.metric || ''
const threshold = tier.threshold || 0
//
const progress = taskProgress[task.id] || {}
let current = 0
if (metric === 'first_order') {
return progress.firstOrder ? '已完成' : '未完成'
} else if (metric === 'order_count') {
current = progress.orderCount || 0
} else if (metric === 'order_amount') {
current = progress.orderAmount || 0
return `¥${current / 100}${threshold / 100}`
} else if (metric === 'invite_count') {
current = progress.inviteCount || 0
}
return `${current}/${threshold}`
}
//
async function claimReward(task, tier) {
const key = `${task.id}_${tier.id}`
if (claiming[key]) return
vibrateShort()
claiming[key] = true
try {
const userId = getUserId()
await claimTaskReward(task.id, userId, tier.id)
//
if (!userProgress.claimedTiers[task.id]) {
userProgress.claimedTiers[task.id] = []
}
userProgress.claimedTiers[task.id].push(tier.id)
uni.showToast({ title: '领取成功!', icon: 'success' })
} catch (e) {
console.error('领取失败:', e)
uni.showToast({ title: e.message || '领取失败', icon: 'none' })
} finally {
claiming[key] = false
}
}
//
async function onRefresh() {
isRefreshing.value = true
await fetchData()
isRefreshing.value = false
}
//
async function fetchData() {
if (!checkAuth()) return
loading.value = true
try {
const userId = getUserId()
//
const res = await getTasks(1, 50)
const list = res.list || res.data || []
tasks.value = list
//
if (list.length > 0 && Object.keys(expandedTasks).length === 0) {
expandedTasks[list[0].id] = true
}
//
if (list.length > 0) {
//
userProgress.orderCount = 0
userProgress.orderAmount = 0
userProgress.inviteCount = 0
userProgress.firstOrder = false
userProgress.claimedTiers = {}
//
const progressPromises = list.map(t =>
getTaskProgress(t.id, userId).catch(err => {
console.warn(`[Tasks] 获取任务 ${t.id} 进度失败:`, err)
return null
})
)
const progressResults = await Promise.allSettled(progressPromises)
progressResults.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
const p = result.value
const taskId = list[index].id
// BUG
taskProgress[taskId] = {
orderCount: p.order_count || 0,
orderAmount: p.order_amount || 0,
inviteCount: p.invite_count || 0,
firstOrder: p.first_order || false
}
// ( - )
userProgress.orderCount = Math.max(userProgress.orderCount, p.order_count || 0)
userProgress.orderAmount = Math.max(userProgress.orderAmount, p.order_amount || 0)
userProgress.inviteCount = Math.max(userProgress.inviteCount, p.invite_count || 0)
if (p.first_order) userProgress.firstOrder = true
//
userProgress.claimedTiers[taskId] = p.claimed_tiers || []
}
})
console.log('[Tasks] 汇总后的进度数据:', userProgress)
}
} catch (e) {
console.error('获取任务失败:', e)
} finally {
loading.value = false
}
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;
text-transform: uppercase;
letter-spacing: 2rpx;
font-weight: 600;
}
/* 进度统计卡片 */
.progress-card {
@extend .glass-card;
margin: 0 $spacing-lg $spacing-lg;
padding: 30rpx;
}
.progress-header {
margin-bottom: 24rpx;
}
.progress-title {
font-size: 26rpx;
font-weight: 700;
color: $text-sub;
}
.progress-stats {
display: flex;
align-items: center;
justify-content: space-around;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
font-family: 'DIN Alternate', sans-serif;
line-height: 1.2;
}
.stat-value.highlight {
color: $brand-primary;
}
.stat-value.first-order-check {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.05);
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
color: $text-tertiary;
&.done {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
}
}
.stat-label {
font-size: 22rpx;
color: $text-tertiary;
margin-top: 8rpx;
font-weight: 500;
}
.stat-divider {
width: 1px;
height: 50rpx;
background: $border-color-light;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 400rpx);
padding: 0 $spacing-lg $spacing-lg;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
margin-bottom: 12rpx;
}
.empty-hint {
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
/* 任务列表 */
.task-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 任务卡片 */
.task-card {
background: #fff;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-sm;
animation: fadeInUp 0.5s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-header {
padding: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
&:active {
background: rgba(0, 0, 0, 0.02);
}
}
.task-info {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
.task-icon {
font-size: 40rpx;
margin-right: 16rpx;
flex-shrink: 0;
}
.task-meta {
flex: 1;
overflow: hidden;
}
.task-name {
font-size: 30rpx;
font-weight: 700;
color: $text-main;
display: block;
margin-bottom: 4rpx;
}
.task-desc {
font-size: 24rpx;
color: $text-sub;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: 16rpx;
}
.task-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 100rpx;
margin-right: 8rpx;
&.status-done {
background: rgba($uni-color-success, 0.1);
color: $uni-color-success;
}
&.status-claimable {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
font-weight: 700;
}
&.status-progress {
background: rgba($brand-primary, 0.05);
color: $text-sub;
}
&.status-waiting {
background: #f5f5f5;
color: $text-tertiary;
}
}
.expand-arrow {
font-size: 28rpx;
color: $text-tertiary;
transition: transform 0.3s;
&.expanded {
transform: rotate(90deg);
}
}
/* 档位列表 */
.tier-list {
border-top: 1rpx solid $border-color-light;
padding: 16rpx 24rpx 24rpx;
background: #fafafa;
}
.tier-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 20rpx;
background: #fff;
border-radius: $radius-md;
margin-bottom: 12rpx;
border: 1rpx solid $border-color-light;
&:last-child {
margin-bottom: 0;
}
&.tier-claimed {
background: #f5f5f5;
opacity: 0.7;
}
&.tier-claimable {
border-color: $brand-primary;
background: rgba($brand-primary, 0.02);
}
}
.tier-left {
flex: 1;
overflow: hidden;
}
.tier-condition {
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.tier-badge {
background: $text-main;
color: #fff;
font-size: 18rpx;
padding: 4rpx 10rpx;
border-radius: 6rpx;
margin-right: 12rpx;
font-weight: 700;
}
.tier-text {
font-size: 26rpx;
color: $text-main;
font-weight: 500;
}
.tier-reward {
display: flex;
align-items: center;
}
.reward-icon {
font-size: 20rpx;
margin-right: 6rpx;
}
.reward-text {
font-size: 22rpx;
color: $brand-primary;
}
.tier-right {
flex-shrink: 0;
margin-left: 16rpx;
}
.tier-btn {
padding: 10rpx 24rpx;
border-radius: 100rpx;
font-size: 24rpx;
font-weight: 600;
&.claimed {
background: #eee;
color: $text-tertiary;
}
&.claimable {
background: $brand-primary;
color: #fff;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.3);
&:active {
transform: scale(0.95);
}
}
}
.tier-progress {
padding: 10rpx 16rpx;
}
.progress-text {
font-size: 24rpx;
color: $text-sub;
font-family: 'DIN Alternate', sans-serif;
}
.no-tier-hint {
padding: 30rpx;
text-align: center;
color: $text-tertiary;
font-size: 24rpx;
background: #fafafa;
border-top: 1rpx solid $border-color-light;
}
/* 加载动画 */
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -3,7 +3,8 @@
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "uni-app"
"navigationBarTitleText": "柯大鸭",
"enablePullDownRefresh": true
}
},
{
@ -18,16 +19,10 @@
"navigationBarTitleText": "商城"
}
},
{
"path": "pages/shop/detail",
"style": {
"navigationBarTitleText": "商品详情"
}
},
{
"path": "pages/cabinet/index",
"style": {
"navigationBarTitleText": "柜"
"navigationBarTitleText": "盒柜"
}
},
{
@ -35,87 +30,206 @@
"style": {
"navigationBarTitleText": "我的"
}
}
],
"subPackages": [
{
"root": "pages-activity",
"pages": [
{
"path": "activity/yifanshang/index",
"style": {
"navigationBarTitleText": "一番赏"
}
},
{
"path": "activity/wuxianshang/index",
"style": {
"navigationBarTitleText": "无限赏"
}
},
{
"path": "activity/duiduipeng/index",
"style": {
"navigationBarTitleText": "对对碰"
}
},
{
"path": "activity/list/index",
"style": {
"navigationBarTitleText": "活动列表"
}
},
{
"path": "activity/pata/index",
"style": {
"navigationBarTitleText": "爬塔"
}
}
]
},
{
"path": "pages/points/index",
"style": {
"navigationBarTitleText": "积分记录"
}
"root": "pages-user",
"pages": [
{
"path": "points/index",
"style": {
"navigationBarTitleText": "积分记录"
}
},
{
"path": "coupons/index",
"style": {
"navigationBarTitleText": "我的优惠券"
}
},
{
"path": "item-cards/index",
"style": {
"navigationBarTitleText": "我的道具卡"
}
},
{
"path": "invite/landing",
"style": {
"navigationBarTitleText": "好友邀请"
}
},
{
"path": "invites/index",
"style": {
"navigationBarTitleText": "邀请记录"
}
},
{
"path": "tasks/index",
"style": {
"navigationBarTitleText": "任务中心"
}
},
{
"path": "orders/index",
"style": {
"navigationBarTitleText": "我的订单"
}
},
{
"path": "orders/detail",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{
"path": "address/index",
"style": {
"navigationBarTitleText": "地址管理"
}
},
{
"path": "address/edit",
"style": {
"navigationBarTitleText": "编辑地址"
}
},
{
"path": "address/submit",
"style": {
"navigationBarTitleText": "填写收货信息"
}
},
{
"path": "help/index",
"style": {
"navigationBarTitleText": "使用帮助"
}
},
{
"path": "agreement/user",
"style": {
"navigationBarTitleText": "用户协议"
}
},
{
"path": "agreement/purchase",
"style": {
"navigationBarTitleText": "购买协议"
}
},
{
"path": "settings/index",
"style": {
"navigationBarTitleText": "设置",
"navigationStyle": "custom",
"mp-toutiao": {
"navigationStyle": "default"
}
}
}
]
},
{
"path": "pages/orders/index",
"style": {
"navigationBarTitleText": "我的订单"
}
"root": "pages-shop",
"pages": [
{
"path": "shop/detail",
"style": {
"navigationBarTitleText": "商品详情"
}
}
]
},
{
"path": "pages/address/index",
"style": {
"navigationBarTitleText": "地址管理"
}
},
{
"path": "pages/address/edit",
"style": {
"navigationBarTitleText": "编辑地址"
}
},
{
"path": "pages/help/index",
"style": {
"navigationBarTitleText": "使用帮助"
}
},
{
"path": "pages/agreement/user",
"style": {
"navigationBarTitleText": "用户协议"
}
},
{
"path": "pages/agreement/purchase",
"style": {
"navigationBarTitleText": "购买协议"
}
},
{
"path": "pages/activity/yifanshang/index",
"style": {
"navigationBarTitleText": "一番赏"
}
},
{
"path": "pages/activity/wuxianshang/index",
"style": {
"navigationBarTitleText": "无限赏"
}
},
{
"path": "pages/activity/duiduipeng/index",
"style": {
"navigationBarTitleText": "对对碰"
}
},
{
"path": "pages/activity/list/index",
"style": {
"navigationBarTitleText": "活动列表"
}
},
{
"path": "pages/activity/pata/index",
"style": {
"navigationBarTitleText": "爬塔"
}
},
{
"path": "pages/register/register",
"style": {
"navigationBarTitleText": ""
}
"root": "pages-game",
"pages": [
{
"path": "game/minesweeper/index",
"style": {
"navigationStyle": "default",
"navigationBarTitleText": "扫雷 game"
}
},
{
"path": "game/minesweeper/play",
"style": {
"navigationStyle": "default",
"navigationBarTitleText": "扫雷对战",
"disableScroll": true,
"mp-weixin": {
"disableSwipeBack": true,
"enablePullDownRefresh": false,
"disableShareMenu": true,
"disableScroll": true,
"disableScale": true
},
"h5": {
"titleNView": false
},
"app-plus": {
"bounce": "none"
}
}
},
{
"path": "game/minesweeper/room-list",
"style": {
"navigationStyle": "default",
"navigationBarTitleText": "对战列表",
"disableScroll": true
}
},
{
"path": "game/webview",
"style": {
"navigationBarTitleText": "游戏挑战",
"navigationBarBackgroundColor": "#000000",
"navigationBarTextStyle": "white"
}
}
]
}
],
"tabBar": {
"custom": true,
"color": "#7A7E83",
"selectedColor": "#007AFF",
"backgroundColor": "#FFFFFF",
@ -135,7 +249,7 @@
},
{
"pagePath": "pages/cabinet/index",
"text": "柜",
"text": "柜",
"iconPath": "static/tab/box.png",
"selectedIconPath": "static/tab/box_active.png"
},
@ -153,5 +267,11 @@
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"easycom": {
"autoscan": true,
"custom": {
"^BlessingAnimation": "@/components/BlessingAnimation.vue"
}
},
"uniIdRouter": {}
}

View File

@ -1,656 +0,0 @@
<template>
<scroll-view class="page" scroll-y>
<view class="banner" v-if="detail.banner">
<image class="banner-img" :src="detail.banner" mode="widthFix" />
</view>
<view class="header">
<view class="title">{{ detail.name || detail.title || '-' }}</view>
<view class="meta" v-if="detail.price_draw !== undefined">参与价{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</view>
</view>
<view class="issues" v-if="showIssues">
<view class="issues-title">期数</view>
<view v-if="issues.length" class="issues-list">
<picker-view class="issue-picker" :value="[selectedIssueIndex]" @change="onIssueChange">
<picker-view-column>
<view class="picker-item" v-for="it in issues" :key="it.id">{{ it.title || ('' + (it.no || it.index || it.issue_no || '-') + '') }}</view>
</picker-view-column>
</picker-view>
<view class="tabs">
<view class="tab" :class="{ active: tabActive === 'pool' }" @click="tabActive = 'pool'">本机奖池</view>
<view class="tab" :class="{ active: tabActive === 'records' }" @click="tabActive = 'records'">中奖记录</view>
</view>
<view v-show="tabActive === 'pool'">
<view class="rewards-grid" v-if="currentIssueId && rewardsMap[currentIssueId] && rewardsMap[currentIssueId].length">
<view v-for="(rw, idx) in rewardsMap[currentIssueId]" :key="rw.id"
class="reward-card animate-stagger"
:style="{ '--delay': idx * 0.05 + 's' }">
<view class="card-header">
<text class="card-title">{{ rw.title }}</text>
<text v-if="rw.boss" class="badge-boss">BOSS</text>
</view>
<view class="image-wrapper">
<image v-if="rw.image" class="reward-image" :src="rw.image" mode="aspectFill" />
<text class="prob-tag absolute-tag">概率 {{ rw.percent }}%</text>
</view>
</view>
</view>
<view class="empty-state" v-else>
<text class="empty-icon">📭</text>
<text class="empty-text">暂无奖励配置</text>
</view>
</view>
<view v-show="tabActive === 'records'">
<view class="records-list" v-if="winRecords.length">
<view v-for="(it, idx) in winRecords" :key="it.id"
class="record-item animate-stagger"
:style="{ '--delay': idx * 0.05 + 's' }">
<image class="record-img" :src="it.image" mode="aspectFill" />
<view class="record-info">
<view class="record-title">{{ it.title }}</view>
<view class="record-meta">
<text class="record-count">x{{ it.count }}</text>
<text v-if="it.percent !== undefined">占比 {{ it.percent }}%</text>
</view>
</view>
</view>
</view>
<view class="empty-state" v-else>
<text class="empty-icon">📝</text>
<text class="empty-text">暂无中奖记录</text>
</view>
</view>
</view>
<view v-else class="issues-empty">暂无期数</view>
</view>
</scroll-view>
<view class="float-bar">
<button class="action-btn primary" @click="onParticipate">
立即参与
<view class="btn-shine"></view>
</button>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import ElCard from '../../../components/ElCard.vue'
import { onLoad } from '@dcloudio/uni-app'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, drawActivityIssue, getActivityWinRecords } from '../../../api/appUser'
const detail = ref({})
const statusText = ref('')
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const showIssues = computed(() => (detail.value && detail.value.status !== 2))
const activityId = ref('')
const tabActive = ref('pool')
const winRecords = ref([])
function statusToText(s) {
if (s === 1) return '进行中'
if (s === 0) return '未开始'
if (s === 2) return '已结束'
return String(s || '')
}
async function fetchDetail(id) {
const data = await getActivityDetail(id)
detail.value = data || {}
statusText.value = statusToText(detail.value.status)
}
function unwrap(list) {
if (Array.isArray(list)) return list
const obj = list || {}
const data = obj.data || {}
const arr = obj.list || obj.items || data.list || data.items || data
return Array.isArray(arr) ? arr : []
}
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function truthy(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
if (!s) return false
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
}
function detectBoss(i) {
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
}
function normalizeIssues(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
no: i.no ?? i.index ?? i.issue_no ?? i.issue_number ?? null,
status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '')
}))
}
function normalizeRewards(list) {
const arr = unwrap(list)
const items = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
weight: Number(i.weight) || 0,
boss: detectBoss(i)
}))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => ({
...it,
percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0
}))
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function normalizeWinRecords(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? i.record_id ?? i.product_id ?? String(idx),
title: i.title ?? i.name ?? i.product_name ?? '',
image: cleanUrl(i.image ?? i.img ?? i.pic ?? i.product_image ?? ''),
count: Number(i.count ?? i.total ?? i.qty ?? 1) || 1,
percent: i.percent !== undefined ? Math.round(Number(i.percent) * 10) / 10 : undefined
}))
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
return now - v < 24 * 60 * 60 * 1000
}
function getRewardCache() {
const obj = uni.getStorageSync('reward_cache_v1') || {}
return typeof obj === 'object' && obj ? obj : {}
}
async function fetchRewardsForIssues(activityId) {
const list = issues.value || []
const cache = getRewardCache()
const act = cache[activityId] || {}
const toFetch = []
list.forEach(it => {
const c = act[it.id]
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
rewardsMap.value = { ...(rewardsMap.value || {}), [it.id]: c.value }
} else {
toFetch.push(it)
}
})
if (!toFetch.length) return
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
const nextAct = { ...act }
results.forEach((res, i) => {
const issueId = toFetch[i] && toFetch[i].id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value) : []
rewardsMap.value = { ...(rewardsMap.value || {}), [issueId]: value }
nextAct[issueId] = { value, ts: Date.now() }
})
cache[activityId] = nextAct
uni.setStorageSync('reward_cache_v1', cache)
}
async function fetchIssues(id) {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(id)
}
async function fetchWinRecords(activityId) {
try {
const data = await getActivityWinRecords(activityId, 1, 50)
winRecords.value = normalizeWinRecords(data)
} catch (e) {
winRecords.value = []
}
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
let maxNo = -Infinity
arr.forEach(i => {
const n = Number(i.no)
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
maxNo = n
latest = i.id
}
})
return latest || (arr[0] && arr[0].id) || ''
}
function setSelectedById(id) {
const arr = issues.value || []
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onIssueChange(e) {
const v = e && e.detail && e.detail.value
const idx = Array.isArray(v) ? (v[0] || 0) : 0
const arr = issues.value || []
const bounded = Math.min(Math.max(0, idx), arr.length - 1)
selectedIssueIndex.value = bounded
const cur = arr[bounded]
currentIssueId.value = (cur && cur.id) || ''
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
async function onParticipate() {
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
uni.showLoading({ title: '抽选中...' })
try {
const res = await drawActivityIssue(aid, iid)
uni.hideLoading()
const obj = res || {}
const data = obj.data || obj.result || obj.reward || obj.item || obj
const name = String((data && (data.title || data.name || data.product_name)) || '未知奖励')
const img = String((data && (data.image || data.img || data.pic || data.product_image)) || '')
uni.showModal({ title: '抽选结果', content: '恭喜获得:' + name, showCancel: false, success: () => { if (img) uni.previewImage({ urls: [img], current: img }) } })
} catch (err) {
uni.hideLoading()
const msg = String((err && (err.message || err.msg)) || '抽选失败')
uni.showToast({ title: msg, icon: 'none' })
}
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
fetchWinRecords(id)
}
ensureElCard()
})
</script>
<style lang="scss" scoped>
/* ============================================
对对碰活动页面 - 高级设计重构 (SCSS Integration)
============================================ */
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80rpx);
opacity: 0.6;
}
.orb-1 {
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.2) 0%, transparent 70%);
top: -100rpx; left: -100rpx;
}
.orb-2 {
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.15) 0%, transparent 70%);
bottom: -100rpx; right: -100rpx;
}
.page-content {
flex: 1;
position: relative;
z-index: 1;
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
/* Banner */
.banner-wrapper {
margin: $spacing-md $spacing-lg;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-lg;
position: relative;
animation: fadeInDown 0.6s ease-out;
}
.banner-img {
width: 100%;
display: block;
}
.banner-shadow {
position: absolute;
bottom: 0; left: 0; width: 100%; height: 40%;
background: linear-gradient(to top, rgba(0,0,0,0.3), transparent);
}
/* Header */
.header-section {
padding: 0 $spacing-lg;
margin-bottom: $spacing-lg;
text-align: center;
animation: fadeIn 0.8s ease-out;
}
.title-row {
margin-bottom: $spacing-sm;
}
.title-text {
font-size: $font-xxl;
font-weight: 900;
background: $gradient-brand;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.price-tag {
display: inline-flex;
align-items: baseline;
background: rgba($bg-card, 0.6);
padding: $spacing-xs $spacing-lg;
border-radius: $radius-round;
backdrop-filter: blur(20rpx);
box-shadow: $shadow-sm;
}
.price-label { font-size: $font-sm; color: $text-sub; margin-right: $spacing-xs; }
.price-symbol { font-size: $font-sm; color: $brand-primary; font-weight: 700; }
.price-value { font-size: $font-xl; color: $brand-primary; font-weight: 900; font-family: 'DIN Alternate', sans-serif; }
/* Glass Card */
.glass-card {
margin: 0 $spacing-lg $spacing-lg;
background: rgba($bg-card, 0.8);
backdrop-filter: blur(40rpx);
border-radius: $radius-xl;
padding: $spacing-lg;
box-shadow: $shadow-card;
border: 1rpx solid rgba(255, 255, 255, 0.6);
animation: fadeInUp 0.6s ease-out 0.2s backwards;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
padding: 0 4rpx;
}
.section-title {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
position: relative;
padding-left: 20rpx;
&::before {
content: '';
position: absolute;
left: 0; top: 50%; transform: translateY(-50%);
width: 8rpx; height: 32rpx;
background: $gradient-brand;
border-radius: 4rpx;
}
}
.issue-indicator {
font-size: $font-sm;
color: $brand-primary;
background: rgba($brand-primary, 0.1);
padding: 4rpx $spacing-md;
border-radius: $radius-round;
font-weight: 600;
}
/* Custom Picker */
.custom-picker {
height: 280rpx;
background: rgba($bg-secondary, 0.5);
border-radius: $radius-lg;
margin-bottom: $spacing-lg;
overflow: hidden;
}
.picker-item {
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-md;
}
.picker-text { font-size: $font-lg; color: $text-main; font-weight: 600; }
.picker-status {
font-size: $font-xs; color: $text-sub; background: rgba(0,0,0,0.05); padding: 2rpx $spacing-sm; border-radius: $radius-sm;
&.status-active { background: #D1FAE5; color: #059669; }
}
/* Modern Tabs */
.modern-tabs {
display: flex;
background: $bg-secondary;
padding: 8rpx;
border-radius: $radius-lg;
margin-bottom: $spacing-lg;
}
.tab-item {
flex: 1;
text-align: center;
padding: $spacing-md 0;
font-size: $font-md;
color: $text-sub;
border-radius: $radius-md;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&.active {
background: #FFFFFF;
color: $brand-primary;
box-shadow: $shadow-sm;
}
}
.active-dot {
width: 8rpx; height: 8rpx;
background: $brand-primary;
border-radius: 50%;
position: absolute;
bottom: 8rpx; left: 50%; transform: translateX(-50%);
}
/* Rewards Grid */
.rewards-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-lg;
}
.reward-card {
background: #FFFFFF;
border-radius: $radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-sm;
border: 1rpx solid rgba(0,0,0,0.03);
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-md;
height: 44rpx;
}
.card-title {
font-size: $font-md;
color: $text-main;
font-weight: 600;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 8rpx;
}
.badge-boss {
font-size: $font-xs;
background: $gradient-gold;
color: #78350F;
padding: 2rpx $spacing-sm;
border-radius: $radius-sm;
font-weight: 800;
flex-shrink: 0;
}
.card-body {
flex: 1;
display: flex;
flex-direction: column;
}
.image-wrapper {
width: 100%;
padding-bottom: 100%;
position: relative;
background: $bg-secondary;
border-radius: $radius-md;
overflow: hidden;
margin-bottom: $spacing-sm;
}
.reward-image {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
}
.prob-tag {
position: absolute;
top: 8rpx; left: 8rpx;
font-size: $font-xs;
color: #fff;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4rpx);
padding: 4rpx $spacing-sm;
border-radius: $radius-sm;
z-index: 2;
}
/* Records List */
.records-list {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.record-item {
display: flex;
background: #FFFFFF;
padding: $spacing-lg;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
align-items: center;
}
.record-img {
width: 100rpx; height: 100rpx;
border-radius: $radius-md;
background: $bg-secondary;
margin-right: $spacing-lg;
}
.record-info {
flex: 1;
}
.record-title {
font-size: $font-md;
font-weight: 600;
color: $text-main;
margin-bottom: $spacing-xs;
}
.record-meta {
display: flex;
gap: $spacing-md;
font-size: $font-sm;
color: $text-sub;
}
.record-count {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
padding: 2rpx $spacing-sm;
border-radius: $radius-sm;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
color: $text-placeholder;
}
.empty-icon { font-size: 80rpx; margin-bottom: $spacing-lg; opacity: 0.5; }
.empty-text { font-size: $font-md; }
/* Float Bar */
.float-bar {
position: fixed;
left: 0; right: 0; bottom: 0;
padding: $spacing-lg $spacing-xl;
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
box-shadow: 0 -8rpx 30rpx rgba(0, 0, 0, 0.05);
z-index: 100;
animation: slideUp 0.4s ease-out backwards;
}
.action-btn {
height: 96rpx;
border-radius: $radius-round;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-xl;
font-weight: 800;
position: relative;
overflow: hidden;
transition: all 0.2s;
&.primary {
background: $gradient-brand;
color: #fff;
box-shadow: $shadow-warm;
}
&:active { transform: scale(0.98); }
}
.btn-shine {
position: absolute;
top: 0; left: -100%; width: 50%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
transform: skewX(-20deg);
animation: shine 3s infinite;
}
/* Animation Utilities */
.animate-stagger {
animation: fadeInUp 0.5s ease-out backwards;
animation-delay: var(--delay, 0s);
}
</style>

View File

@ -1,573 +0,0 @@
<template>
<view class="page-wrapper">
<!-- Rebuild Trigger -->
<!-- 背景层 -->
<image class="bg-fixed" :src="detail.banner || ''" mode="aspectFill" />
<view class="bg-mask"></view>
<view class="content-area">
<!-- 顶部信息 -->
<view class="header-section">
<view class="title-box">
<text class="main-title">{{ detail.name || detail.title || '爬塔挑战' }}</text>
<text class="sub-title">层层突围 赢取大奖</text>
</view>
<view class="rule-btn" @tap="showRules">规则</view>
</view>
<!-- 挑战区域 (模拟塔层) -->
<view class="tower-container">
<view class="tower-level current">
<view class="level-info">
<text class="level-num">当前挑战</text>
<text class="level-name">{{ currentIssueTitle || '第1层' }}</text>
</view>
<view class="level-status">进行中</view>
</view>
<!-- 奖池预览 -->
<view class="rewards-preview" v-if="currentIssueRewards.length">
<scroll-view scroll-x class="rewards-scroll">
<view class="reward-item" v-for="(r, idx) in currentIssueRewards" :key="idx">
<image class="reward-img" :src="r.image" mode="aspectFill" />
<view class="reward-name">{{ r.title }}</view>
<view class="reward-prob" v-if="r.percent">概率 {{ r.percent }}%</view>
</view>
</scroll-view>
</view>
</view>
<!-- 操作区 -->
<view class="action-area">
<view class="price-display">
<text class="currency">¥</text>
<text class="amount">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
<text class="unit">/</text>
</view>
<button class="challenge-btn" :loading="drawLoading" @tap="onStartChallenge">
立即挑战
</button>
</view>
</view>
<!-- 结果弹窗 -->
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
<view class="flip-mask" @tap="closeFlip"></view>
<view class="flip-content">
<FlipGrid ref="flipRef" :rewards="winItems" :controls="false" />
<button class="close-btn" @tap="closeFlip">收下奖励</button>
</view>
</view>
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:propCards="propCards"
@confirm="onPaymentConfirm"
/>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import FlipGrid from '../../../components/FlipGrid.vue'
import PaymentPopup from '../../../components/PaymentPopup.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, joinLottery, createWechatOrder, getLotteryResult, getItemCards, getUserCoupons } from '../../../api/appUser'
const activityId = ref('')
const detail = ref({})
const issues = ref([])
const currentIssueId = ref('')
const rewardsMap = ref({})
const drawLoading = ref(false)
const showFlip = ref(false)
const winItems = ref([])
const flipRef = ref(null)
// Payment
const paymentVisible = ref(false)
const paymentAmount = ref('0.00')
const coupons = ref([])
const propCards = ref([])
const selectedCoupon = ref(null)
const selectedCard = ref(null)
const pendingCount = ref(1)
const currentIssueTitle = computed(() => {
const i = issues.value.find(x => x.id === currentIssueId.value)
return i ? (i.title || `${i.no}`) : ''
})
const currentIssueRewards = computed(() => {
return (currentIssueId.value && rewardsMap.value[currentIssueId.value]) || []
})
const priceVal = computed(() => Number(detail.value.price_draw || 0) / 100)
async function loadData(id) {
try {
const d = await getActivityDetail(id)
detail.value = d || {}
const is = await getActivityIssues(id)
issues.value = normalizeIssues(is)
if (issues.value.length) {
const first = issues.value[0]
currentIssueId.value = first.id
loadRewards(id, first.id)
}
} catch (e) {
console.error(e)
}
}
async function loadRewards(aid, iid) {
try {
const res = await getActivityIssueRewards(aid, iid)
rewardsMap.value[iid] = normalizeRewards(res)
} catch (e) {}
}
function onStartChallenge() {
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) {
uni.showToast({ title: '请先登录', icon: 'none' })
// In real app, redirect to login
return
}
if (!currentIssueId.value) {
uni.showToast({ title: '暂无挑战场次', icon: 'none' })
return
}
paymentAmount.value = priceVal.value.toFixed(2)
pendingCount.value = 1
paymentVisible.value = true
// Fetch coupons/cards in background
fetchPropCards()
fetchCoupons()
}
async function onPaymentConfirm(data) {
selectedCoupon.value = data?.coupon || null
selectedCard.value = data?.card || null
paymentVisible.value = false
await doDraw()
}
async function doDraw() {
drawLoading.value = true
try {
const openid = uni.getStorageSync('openid')
const joinRes = await joinLottery({
activity_id: Number(activityId.value),
issue_id: Number(currentIssueId.value),
channel: 'miniapp',
count: 1,
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0
})
if (!joinRes) throw new Error('下单失败')
const orderNo = joinRes.order_no || joinRes.data?.order_no || joinRes.result?.order_no
// Simulate Wechat Pay flow (simplified)
const payRes = await createWechatOrder({ openid, order_no: orderNo })
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
...payRes,
success: resolve,
fail: reject
})
})
// Get Result
const res = await getLotteryResult(orderNo)
const raw = res.list || res.items || res.data || res.result || (Array.isArray(res) ? res : [res])
winItems.value = raw.map(i => ({
title: i.title || i.name || '未知奖励',
image: i.image || i.img || ''
}))
showFlip.value = true
setTimeout(() => {
if(flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults(winItems.value)
}, 100)
} catch (e) {
uni.showToast({ title: e.message || '挑战失败', icon: 'none' })
} finally {
drawLoading.value = false
}
}
function normalizeIssues(list) {
if (!Array.isArray(list)) return []
return list.map(i => ({
id: i.id,
title: i.title || i.name,
no: i.no,
}))
}
function normalizeRewards(list) {
if (!Array.isArray(list)) return []
return list.map(i => ({
title: i.name || i.title,
image: i.image || i.img || i.pic,
percent: i.percent || 0
}))
}
async function fetchPropCards() { /* implementation same as other pages */ }
async function fetchCoupons() { /* implementation same as other pages */ }
function showRules() {
uni.showModal({ title: '规则', content: detail.value.rules || '暂无规则', showCancel: false })
}
function closeFlip() { showFlip.value = false }
onLoad((opts) => {
if (opts.id) {
activityId.value = opts.id
loadData(opts.id)
}
})
</script>
<style lang="scss" scoped>
/* ============================================
爬塔页面 - 沉浸式暗黑风格 (SCSS Integration)
============================================ */
$local-gold: #FFD700; //
.page-wrapper {
min-height: 100vh;
position: relative;
background: $bg-dark;
color: $text-dark-main;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 背景装饰 - 暗黑版 */
.bg-decoration {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
&::before {
content: '';
position: absolute;
top: -10%; left: -20%;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.1) 0%, transparent 70%);
filter: blur(80rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
bottom: 10%; right: -10%;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($local-gold, 0.08) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.5;
animation: float 12s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20rpx, 30rpx); }
}
.bg-fixed {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
opacity: 0.3;
z-index: 0;
filter: blur(8rpx);
}
.bg-mask {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(180deg, rgba($bg-dark, 0.85), $bg-dark 95%);
z-index: 1;
}
.content-area {
position: relative;
z-index: 2;
flex: 1;
display: flex;
flex-direction: column;
padding: $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
}
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-xl;
animation: fadeInDown 0.6s ease-out;
}
.title-box {
display: flex;
flex-direction: column;
}
.main-title {
font-size: 60rpx;
font-weight: 900;
font-style: italic;
display: block;
text-shadow: 0 4rpx 16rpx rgba(0,0,0,0.6);
background: linear-gradient(180deg, #fff, #b3b3b3);
-webkit-background-clip: text;
color: transparent;
letter-spacing: 2rpx;
}
.sub-title {
font-size: 26rpx;
opacity: 0.8;
margin-top: $spacing-xs;
display: block;
letter-spacing: 4rpx;
color: $brand-primary;
text-transform: uppercase;
}
.rule-btn {
background: rgba(255,255,255,0.1);
border: 1px solid $border-dark;
padding: 12rpx 32rpx;
border-radius: 100rpx;
font-size: 24rpx;
backdrop-filter: blur(10rpx);
transition: all 0.2s;
color: rgba(255,255,255,0.9);
&:active {
background: rgba(255,255,255,0.25);
transform: scale(0.96);
}
}
.tower-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding-bottom: 40rpx;
}
.tower-level {
width: 100%;
background: $bg-dark-card;
backdrop-filter: blur(20rpx);
padding: 48rpx;
border-radius: $radius-xl;
box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.3);
margin-bottom: 40rpx;
border: 1px solid $border-dark;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
overflow: hidden;
animation: zoomIn 0.5s ease-out backwards;
&::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
}
&.current {
background: rgba($local-gold, 0.15);
border-color: rgba($local-gold, 0.5);
box-shadow: 0 0 40rpx rgba($local-gold, 0.15), inset 0 0 20rpx rgba($local-gold, 0.05);
}
}
.level-info { display: flex; flex-direction: column; z-index: 1; }
.level-num {
font-size: 24rpx;
color: $text-dark-sub;
margin-bottom: 8rpx;
text-transform: uppercase;
letter-spacing: 2rpx;
}
.level-name {
font-size: 48rpx;
font-weight: 700;
color: $text-dark-main;
text-shadow: 0 4rpx 8rpx rgba(0,0,0,0.3);
}
.level-status {
font-size: 24rpx;
background: linear-gradient(135deg, $local-gold, $brand-secondary);
color: #3e2723;
padding: 8rpx 20rpx;
border-radius: 12rpx;
font-weight: 800;
box-shadow: 0 4rpx 16rpx rgba($brand-secondary, 0.3);
z-index: 1;
}
.rewards-preview {
width: 100%;
margin-top: 40rpx;
}
.rewards-scroll {
white-space: nowrap;
width: 100%;
}
.reward-item {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 160rpx;
margin-right: 24rpx;
animation: fadeInUp 0.5s ease-out backwards;
@for $i from 1 through 5 {
&:nth-child(#{$i}) {
animation-delay: #{$i * 0.1}s;
}
}
}
.reward-img {
width: 120rpx; height: 120rpx;
border-radius: 24rpx;
background: rgba(255,255,255,0.05);
margin-bottom: 16rpx;
border: 1px solid $border-dark;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2);
}
.reward-name {
font-size: 22rpx;
color: $text-dark-sub;
width: 100%;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.reward-prob {
font-size: 20rpx;
color: $local-gold;
font-weight: 600;
margin-top: 4rpx;
}
.action-area {
background: $bg-dark-card;
backdrop-filter: blur(40rpx);
padding: 24rpx 32rpx;
border-radius: 100rpx;
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid $border-dark;
box-shadow: 0 20rpx 60rpx rgba(0,0,0,0.5);
margin-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
animation: slideUp 0.6s ease-out backwards;
animation-delay: 0.3s;
}
.price-display {
display: flex;
align-items: baseline;
color: $local-gold;
font-weight: 700;
margin-left: 20rpx;
text-shadow: 0 0 20rpx rgba(255, 215, 0, 0.2);
}
.currency { font-size: 28rpx; }
.amount { font-size: 48rpx; margin: 0 4rpx; font-family: 'DIN Alternate', sans-serif; }
.unit { font-size: 24rpx; opacity: 0.8; font-weight: normal; }
.challenge-btn {
background: $gradient-brand;
color: #fff;
font-weight: 900;
border-radius: 100rpx;
padding: 0 60rpx;
height: 88rpx;
line-height: 88rpx;
font-size: 32rpx;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 0, 0.3);
border: none;
position: relative;
overflow: hidden;
transition: all 0.2s;
&::after {
content: '';
position: absolute;
top: 0; left: -100%; width: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
animation: shimmer 3s infinite;
}
&:active {
transform: scale(0.96);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 0, 0.2);
}
}
.flip-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999;
}
.flip-mask {
position: absolute; top: 0; bottom: 0; width: 100%; background: rgba(0,0,0,0.85);
backdrop-filter: blur(10rpx);
animation: fadeIn 0.3s ease-out;
}
.flip-content {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
padding: 40rpx;
justify-content: center;
animation: zoomIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.close-btn {
margin-top: 60rpx;
background: #fff;
color: #333;
border-radius: 100rpx;
font-weight: 700;
width: 50%;
height: 80rpx;
line-height: 80rpx;
align-self: center;
box-shadow: 0 10rpx 30rpx rgba(255,255,255,0.15);
transition: all 0.2s;
&:active {
transform: scale(0.95);
}
}
@keyframes shimmer {
0% { left: -100%; }
50%, 100% { left: 200%; }
}
</style>

View File

@ -1,762 +0,0 @@
<template>
<view class="bg-decoration"></view>
<scroll-view class="page" scroll-y>
<!-- 顶部 Banner -->
<view class="banner" v-if="detail.banner">
<image class="banner-img" :src="detail.banner" mode="widthFix" />
</view>
<!-- 商品信息卡片 -->
<view class="product-card">
<view class="product-info">
<image v-if="detail.banner" class="product-thumb" :src="detail.banner" mode="aspectFill" />
<view class="product-detail">
<view class="product-name">{{ detail.name || detail.title || '无限赏活动' }}</view>
<view class="product-price">¥{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</view>
</view>
<view class="product-actions">
<view class="action-btn">📦 盒柜</view>
</view>
</view>
</view>
<!-- 期号切换条 -->
<view class="issue-bar" v-if="showIssues && issues.length">
<button class="nav-btn" @click="prevIssue"></button>
<view class="issue-info">
<text class="issue-label">{{ currentIssueTitle }}</text>
</view>
<button class="nav-btn" @click="nextIssue"></button>
</view>
<!-- 玩法福利标签 -->
<view class="gameplay-tags">
<view class="tag tag-pool">聚宝盆</view>
<view class="tag tag-drop">随机掉落 10%</view>
<view class="tag tag-free">随机免单 10%</view>
</view>
</scroll-view>
<!-- 底部多档位抽赏按钮 -->
<view class="bottom-actions">
<button class="tier-btn" @click="() => openPayment(1)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 1).toFixed(2) }}</text>
<text class="tier-label">抽1发</text>
</button>
<button class="tier-btn" @click="() => openPayment(3)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 3).toFixed(2) }}</text>
<text class="tier-label">抽3发</text>
</button>
<button class="tier-btn" @click="() => openPayment(5)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 5).toFixed(2) }}</text>
<text class="tier-label">抽5发</text>
</button>
<button class="tier-btn tier-hot" @click="() => openPayment(10)">
<text class="tier-price">¥{{ (pricePerDrawYuan * 10).toFixed(2) }}</text>
<text class="tier-label">抽10发</text>
</button>
</view>
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
<view class="flip-mask" @tap="closeFlip"></view>
<view class="flip-content" @tap.stop>
<FlipGrid ref="flipRef" :rewards="currentIssueRewards" :controls="false" />
<button class="overlay-close" @tap="closeFlip">关闭</button>
</view>
</view>
<PaymentPopup
v-model:visible="paymentVisible"
:amount="paymentAmount"
:coupons="coupons"
:propCards="propCards"
@confirm="onPaymentConfirm"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
import FlipGrid from '../../../components/FlipGrid.vue'
import { onLoad } from '@dcloudio/uni-app'
import PaymentPopup from '../../../components/PaymentPopup.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, joinLottery, createWechatOrder, getLotteryResult, getItemCards, getUserCoupons } from '../../../api/appUser'
const detail = ref({})
const statusText = ref('')
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const showIssues = computed(() => (detail.value && detail.value.status !== 2))
const activityId = ref('')
const drawLoading = ref(false)
const currentIssueRewards = computed(() => (currentIssueId.value && rewardsMap.value[currentIssueId.value]) ? rewardsMap.value[currentIssueId.value] : [])
const currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
const t = (cur && (cur.title || ('第' + (cur.no || '-') + '期'))) || '-'
return t
})
const points = ref(0)
const flipRef = ref(null)
const showFlip = ref(false)
const paymentVisible = ref(false)
const paymentAmount = ref('0.00')
const coupons = ref([])
const propCards = ref([])
const pendingCount = ref(1)
const selectedCoupon = ref(null)
const selectedCard = ref(null)
const pricePerDrawYuan = computed(() => ((Number(detail.value.price_draw || 0) / 100) || 0))
function statusToText(s) {
if (s === 1) return '进行中'
if (s === 0) return '未开始'
if (s === 2) return '已结束'
return String(s || '')
}
async function fetchDetail(id) {
const data = await getActivityDetail(id)
detail.value = data || {}
statusText.value = statusToText(detail.value.status)
}
function unwrap(list) {
if (Array.isArray(list)) return list
const obj = list || {}
const data = obj.data || {}
const arr = obj.list || obj.items || data.list || data.items || data
return Array.isArray(arr) ? arr : []
}
function normalizeIssues(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
no: i.no ?? i.index ?? i.issue_no ?? i.issue_number ?? null,
status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '')
}))
}
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function truthy(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
if (!s) return false
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
}
function detectBoss(i) {
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
}
function normalizeRewards(list) {
const arr = unwrap(list)
const items = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
weight: Number(i.weight) || 0,
boss: detectBoss(i)
}))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => ({
...it,
percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0
}))
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
return now - v < 24 * 60 * 60 * 1000
}
function getRewardCache() {
const obj = uni.getStorageSync('reward_cache_v1') || {}
return typeof obj === 'object' && obj ? obj : {}
}
async function fetchRewardsForIssues(activityId) {
const list = issues.value || []
const cache = getRewardCache()
const act = cache[activityId] || {}
const toFetch = []
list.forEach(it => {
const c = act[it.id]
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
rewardsMap.value = { ...(rewardsMap.value || {}), [it.id]: c.value }
} else {
toFetch.push(it)
}
})
if (!toFetch.length) return
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
const nextAct = { ...act }
results.forEach((res, i) => {
const issueId = toFetch[i] && toFetch[i].id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value) : []
rewardsMap.value = { ...(rewardsMap.value || {}), [issueId]: value }
nextAct[issueId] = { value, ts: Date.now() }
})
cache[activityId] = nextAct
uni.setStorageSync('reward_cache_v1', cache)
}
async function fetchIssues(id) {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(id)
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
let maxNo = -Infinity
arr.forEach(i => {
const n = Number(i.no)
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
maxNo = n
latest = i.id
}
})
return latest || (arr[0] && arr[0].id) || ''
}
function setSelectedById(id) {
const arr = issues.value || []
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onIssueChange(e) {
// deprecated picker
}
function prevIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value - 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function nextIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value + 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
function openPayment(count) {
const times = Math.max(1, Number(count || 1))
pendingCount.value = times
paymentAmount.value = (pricePerDrawYuan.value * times).toFixed(2)
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
paymentVisible.value = true
fetchPropCards()
fetchCoupons()
}
async function onPaymentConfirm(data) {
selectedCoupon.value = data && data.coupon ? data.coupon : null
selectedCard.value = data && data.card ? data.card : null
paymentVisible.value = false
await onMachineDraw(pendingCount.value)
}
async function fetchPropCards() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getItemCards(user_id)
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
propCards.value = list.map((i, idx) => ({
id: i.id ?? i.card_id ?? String(idx),
name: i.name ?? i.title ?? '道具卡'
}))
} catch (e) {
propCards.value = []
}
}
async function fetchCoupons() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getUserCoupons(user_id, 0, 1, 100)
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
coupons.value = list.map((i, idx) => {
const amountCents = (i.remaining !== undefined && i.remaining !== null) ? Number(i.remaining) : Number(i.amount ?? i.value ?? 0)
const amt = isNaN(amountCents) ? 0 : (amountCents / 100)
return {
id: i.id ?? i.coupon_id ?? String(idx),
name: i.name ?? i.title ?? '优惠券',
amount: Number(amt).toFixed(2)
}
})
} catch (e) {
coupons.value = []
}
}
async function onMachineDraw(count) {
showFlip.value = true
try { if (flipRef.value && flipRef.value.reset) flipRef.value.reset() } catch (_) {}
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
const openid = uni.getStorageSync('openid')
if (!openid) { uni.showToast({ title: '缺少OpenID请重新登录', icon: 'none' }); return }
drawLoading.value = true
try {
const times = Math.max(1, Number(count || 1))
const joinRes = await joinLottery({
activity_id: Number(aid),
issue_id: Number(iid),
channel: 'miniapp',
count: times,
coupon_id: selectedCoupon.value && selectedCoupon.value.id ? Number(selectedCoupon.value.id) : 0,
item_card_id: selectedCard.value && selectedCard.value.id ? Number(selectedCard.value.id) : 0
})
const orderNo = joinRes && (joinRes.order_no || joinRes.data?.order_no || joinRes.result?.order_no)
if (!orderNo) throw new Error('未获取到订单号')
const payRes = await createWechatOrder({ openid, order_no: orderNo })
await new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'MD5',
paySign: payRes.paySign,
success: resolve,
fail: reject
})
})
const resultRes = await getLotteryResult(orderNo)
const raw = resultRes && (resultRes.list || resultRes.items || resultRes.data || resultRes.result || resultRes)
const arr = Array.isArray(raw) ? raw : (Array.isArray(resultRes?.data) ? resultRes.data : [raw])
const items = arr.filter(Boolean).map(d => {
const title = String((d && (d.title || d.name || d.product_name)) || '奖励')
const image = String((d && (d.image || d.img || d.pic || d.product_image)) || '')
return { title, image }
})
if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults(items)
} catch (e) {
if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults([{ title: e.message || '抽选失败', image: '' }])
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
drawLoading.value = false
}
}
function onMachineTry() {
const list = rewardsMap.value[currentIssueId.value] || []
if (!list.length) { uni.showToast({ title: '暂无奖池', icon: 'none' }); return }
const idx = Math.floor(Math.random() * list.length)
const it = list[idx]
uni.showModal({ title: '试一试', content: it.title || '随机预览', showCancel: false, success: () => { if (it.image) uni.previewImage({ urls: [it.image], current: it.image }) } })
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
}
})
function closeFlip() { showFlip.value = false }
</script>
<style lang="scss" scoped>
/* 奇盒潮玩 - 无限赏活动页面 */
.page {
min-height: 100vh;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
background: transparent;
position: relative;
z-index: 1;
}
.bg-decoration {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background-color: $bg-page;
z-index: 0;
overflow: hidden;
pointer-events: none;
&::before, &::after {
content: '';
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.5;
}
&::before {
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.12), transparent 70%);
top: -200rpx;
left: -200rpx;
animation: float 10s ease-in-out infinite;
}
&::after {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.15), transparent 70%);
bottom: 10%;
right: -100rpx;
animation: float 12s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
.banner {
padding: $spacing-lg $spacing-lg 0;
animation: fadeInDown 0.6s $ease-out;
}
.banner-img {
width: 100%;
border-radius: $radius-lg;
box-shadow: $shadow-lg;
}
/* 商品信息卡片 */
.product-card {
margin: $spacing-lg;
background: $bg-glass;
backdrop-filter: blur(20rpx);
border-radius: $radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-card;
animation: fadeInUp 0.6s $ease-out 0.1s backwards;
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.product-info {
display: flex;
align-items: flex-start;
gap: $spacing-lg;
}
.product-thumb {
width: 140rpx;
height: 140rpx;
border-radius: $radius-md;
flex-shrink: 0;
background: $bg-page;
box-shadow: $shadow-inner;
}
.product-detail {
flex: 1;
min-width: 0;
}
.product-name {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
margin-bottom: $spacing-sm;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.product-price {
font-size: $font-xl;
font-weight: 800;
color: $brand-primary;
font-family: 'DIN Alternate', sans-serif;
}
.product-actions {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.action-btn {
background: rgba($brand-primary, 0.05);
border: 1rpx solid rgba($brand-primary, 0.2);
border-radius: $radius-sm;
padding: $spacing-sm $spacing-lg;
font-size: $font-sm;
color: $brand-primary-dark;
text-align: center;
font-weight: 600;
transition: all $transition-fast;
}
.action-btn:active {
background: rgba($brand-primary, 0.1);
transform: scale(0.95);
}
/* 期号切换条 */
.issue-bar {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-lg;
margin: 0 $spacing-lg $spacing-lg;
padding: $spacing-md $spacing-lg;
background: $bg-glass;
backdrop-filter: blur(20rpx);
border-radius: $radius-round;
box-shadow: $shadow-sm;
animation: fadeInUp 0.6s $ease-out 0.2s backwards;
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.nav-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: $bg-page;
color: $text-sub;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-sm;
padding: 0;
margin: 0;
line-height: 1;
transition: all $transition-fast;
border: none;
&:active {
background: darken($bg-page, 5%);
transform: scale(0.9);
}
}
.issue-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
min-width: 200rpx;
}
.issue-label {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
}
/* 玩法福利标签 */
.gameplay-tags {
display: flex;
gap: $spacing-md;
padding: 0 $spacing-lg;
margin-bottom: $spacing-lg;
flex-wrap: wrap;
animation: fadeInUp 0.6s $ease-out 0.3s backwards;
}
.tag {
padding: $spacing-sm $spacing-lg;
border-radius: $radius-round;
font-size: $font-sm;
font-weight: 600;
display: flex;
align-items: center;
box-shadow: $shadow-sm;
backdrop-filter: blur(4px);
}
.tag-pool {
background: $color-success;
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba($color-success, 0.3);
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
.tag-drop {
background: $gradient-brand;
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.3);
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
.tag-free {
background: $gradient-gold;
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba($accent-gold, 0.3);
text-shadow: 0 1rpx 2rpx rgba(0,0,0,0.1);
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
/* 底部多档位抽赏按钮 */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: $spacing-md;
padding: $spacing-lg $spacing-lg;
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20rpx);
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.08);
z-index: 999;
animation: slideUp $transition-slow $ease-out backwards;
border-top: 1rpx solid rgba(0,0,0,0.05);
}
.tier-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-md $spacing-xs;
background: $bg-card;
border: 1rpx solid $border-color-light;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
transition: all $transition-fast;
&:active {
transform: scale(0.95);
background: $bg-page;
}
}
.tier-price {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
font-family: 'DIN Alternate', sans-serif;
}
.tier-label {
font-size: $font-xs;
color: $text-sub;
margin-top: 4rpx;
font-weight: 500;
}
.tier-hot {
background: $gradient-brand;
border: none;
box-shadow: $shadow-warm;
position: relative;
overflow: hidden;
.tier-price, .tier-label {
color: #fff;
}
&::after {
content: 'HOT';
position: absolute;
top: 0;
right: 0;
background: linear-gradient(135deg, $accent-red, #D32F2F);
color: #fff;
font-size: 18rpx;
font-weight: 800;
padding: 4rpx 10rpx;
border-bottom-left-radius: $radius-md;
box-shadow: -2rpx 2rpx 4rpx rgba(0,0,0,0.1);
}
&:active {
opacity: 0.9;
transform: scale(0.96);
}
}
.tier-hot .tier-price, .tier-hot .tier-label {
color: #FFFFFF;
}
/* 翻牌弹窗 */
.flip-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10000;
animation: fadeIn 0.3s ease-out;
}
.flip-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(10px);
z-index: 1;
}
.flip-content {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
padding: 24rpx;
z-index: 2;
justify-content: center;
align-items: center;
animation: zoomIn 0.3s $ease-bounce;
}
.overlay-close {
margin-top: 60rpx;
width: 240rpx;
height: 88rpx;
line-height: 88rpx;
background: rgba(255,255,255,0.15) !important;
border: 1rpx solid rgba(255,255,255,0.3);
color: #FFFFFF !important;
border-radius: $radius-round;
font-weight: 600;
font-size: 30rpx;
backdrop-filter: blur(10px);
transition: all $transition-fast;
&:active {
background: rgba(255,255,255,0.25) !important;
transform: scale(0.95);
}
}
</style>

View File

@ -1,803 +0,0 @@
<template>
<view class="page-wrapper">
<!-- 背景装饰 -->
<view class="bg-decoration">
<view class="orb orb-1"></view>
<view class="orb orb-2"></view>
</view>
<!-- 顶部背景图模糊处理 -->
<view class="page-bg">
<image class="bg-image" :src="detail.banner" mode="aspectFill" />
<view class="bg-mask"></view>
</view>
<!-- 导航栏占位如果有自定义导航栏需求 -->
<!-- <view class="nav-bar-placeholder"></view> -->
<!-- 主要内容区域 -->
<scroll-view class="main-scroll" scroll-y>
<!-- 头部信息卡片 -->
<view class="header-card animate-enter">
<image class="header-cover" :src="detail.banner" mode="aspectFill" />
<view class="header-info">
<view class="header-title">{{ detail.name || detail.title || '一番赏活动' }}</view>
<view class="header-price-row">
<text class="price-symbol">¥</text>
<text class="price-num">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
<text class="price-unit">/</text>
</view>
<view class="header-tags">
<view class="tag-item">超高爆率</view>
<view class="tag-item">公平公正</view>
</view>
</view>
<view class="header-actions">
<view class="action-btn" @tap="showRules">
<text class="icon">📋</text>
<text>规则</text>
</view>
<view class="action-btn" @tap="goCabinet">
<text class="icon">📦</text>
<text>盒柜</text>
</view>
</view>
</view>
<!-- 赏品概览 -->
<view class="section-container animate-enter stagger-1" v-if="currentIssueRewards.length > 0">
<view class="section-header">
<text class="section-title">赏品一览</text>
<text class="section-more">查看全部 ></text>
</view>
<scroll-view class="preview-scroll" scroll-x>
<view class="preview-item" v-for="(item, idx) in currentIssueRewards" :key="idx">
<view class="prize-tag" :class="{ 'tag-boss': item.boss }">{{ item.boss ? 'BOSS' : (item.grade || '赏') }}</view>
<image class="preview-img" :src="item.image" mode="aspectFill" />
<view class="preview-name">{{ item.title }}</view>
</view>
</scroll-view>
</view>
<!-- 选号区域 -->
<view class="section-container selector-container animate-enter stagger-2">
<!-- 期号切换 -->
<view class="issue-header">
<view class="issue-switch-btn" @click="prevIssue">
<text class="arrow"></text>
</view>
<view class="issue-info-center">
<text class="issue-current-text">{{ currentIssueTitle }}</text>
<text class="issue-status-badge">进行中</text>
</view>
<view class="issue-switch-btn" @click="nextIssue">
<text class="arrow"></text>
</view>
</view>
<!-- 选号组件 -->
<view class="selector-body" v-if="activityId && currentIssueId">
<YifanSelector
:activity-id="activityId"
:issue-id="currentIssueId"
:price-per-draw="Number(detail.price_draw || 0) / 100"
@payment-success="onPaymentSuccess"
/>
</view>
</view>
<!-- 底部垫高 -->
<view style="height: 180rpx;"></view>
</scroll-view>
</view>
<!-- 翻牌弹窗 -->
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
<view class="flip-mask" @tap="closeFlip"></view>
<view class="flip-content" @tap.stop>
<FlipGrid ref="flipRef" :rewards="currentIssueRewards" :controls="false" />
<button class="overlay-close" @tap="closeFlip">关闭</button>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import FlipGrid from '../../../components/FlipGrid.vue'
import YifanSelector from '@/components/YifanSelector.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getActivityWinRecords } from '../../../api/appUser'
const detail = ref({})
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const showIssues = computed(() => (detail.value && detail.value.status !== 2))
const activityId = ref('')
const tabActive = ref('pool')
const winRecords = ref([])
const drawLoading = ref(false)
const points = ref(0)
const flipRef = ref(null)
const showFlip = ref(false)
const currentIssueRewards = computed(() => (currentIssueId.value && rewardsMap.value[currentIssueId.value]) ? rewardsMap.value[currentIssueId.value] : [])
const currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
const t = (cur && (cur.title || ('第' + (cur.no || '-') + '期'))) || '-'
return t
})
//
const currentIssueRemain = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
return cur && cur.remain !== undefined ? cur.remain : ''
})
//
function showRules() {
uni.showModal({
title: '活动规则',
content: detail.value.rules || '1. 选择号码进行抽选\n2. 每个号码对应一个奖品\n3. 已售号码不可再选',
showCancel: false
})
}
//
function goCabinet() {
uni.navigateTo({ url: '/pages/cabinet/index' })
}
function statusToText(s) {
if (s === 1) return '进行中'
if (s === 0) return '未开始'
if (s === 2) return '已结束'
return String(s || '')
}
const statusText = ref('')
async function fetchDetail(id) {
const data = await getActivityDetail(id)
detail.value = data || {}
statusText.value = statusToText(detail.value.status)
}
function unwrap(list) {
if (Array.isArray(list)) return list
const obj = list || {}
const data = obj.data || {}
const arr = obj.list || obj.items || data.list || data.items || data
return Array.isArray(arr) ? arr : []
}
function normalizeIssues(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
no: i.no ?? i.index ?? i.issue_no ?? i.issue_number ?? null,
status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '')
}))
}
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function truthy(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
if (!s) return false
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
}
function detectBoss(i) {
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
}
function normalizeRewards(list) {
const arr = unwrap(list)
const items = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
weight: Number(i.weight) || 0,
boss: detectBoss(i)
}))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => ({
...it,
percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0
}))
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function normalizeWinRecords(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? i.record_id ?? i.product_id ?? String(idx),
title: i.title ?? i.name ?? i.product_name ?? '',
image: cleanUrl(i.image ?? i.img ?? i.pic ?? i.product_image ?? ''),
count: Number(i.count ?? i.total ?? i.qty ?? 1) || 1,
percent: i.percent !== undefined ? Math.round(Number(i.percent) * 10) / 10 : undefined
}))
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
return now - v < 24 * 60 * 60 * 1000
}
function getRewardCache() {
const obj = uni.getStorageSync('reward_cache_v1') || {}
return typeof obj === 'object' && obj ? obj : {}
}
async function fetchRewardsForIssues(activityId) {
const list = issues.value || []
const cache = getRewardCache()
const act = cache[activityId] || {}
const toFetch = []
list.forEach(it => {
const c = act[it.id]
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
rewardsMap.value = { ...(rewardsMap.value || {}), [it.id]: c.value }
} else {
toFetch.push(it)
}
})
if (!toFetch.length) return
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
const nextAct = { ...act }
results.forEach((res, i) => {
const issueId = toFetch[i] && toFetch[i].id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value) : []
rewardsMap.value = { ...(rewardsMap.value || {}), [issueId]: value }
nextAct[issueId] = { value, ts: Date.now() }
})
cache[activityId] = nextAct
uni.setStorageSync('reward_cache_v1', cache)
}
async function fetchIssues(id) {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(id)
}
async function fetchWinRecords(activityId) {
try {
const data = await getActivityWinRecords(activityId, 1, 50)
winRecords.value = normalizeWinRecords(data)
} catch (e) {
winRecords.value = []
}
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
let maxNo = -Infinity
arr.forEach(i => {
const n = Number(i.no)
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
maxNo = n
latest = i.id
}
})
return latest || (arr[0] && arr[0].id) || ''
}
function setSelectedById(id) {
const arr = issues.value || []
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onIssueChange(e) {
// deprecated picker
}
function prevIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value - 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function nextIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value + 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
function onPaymentSuccess(payload) {
console.log('Payment Success:', payload)
const result = payload.result
let wonItems = []
//
if (Array.isArray(result)) {
wonItems = result
} else if (result && Array.isArray(result.list)) {
wonItems = result.list
} else if (result && Array.isArray(result.data)) {
wonItems = result.data
} else if (result && Array.isArray(result.rewards)) {
wonItems = result.rewards
} else {
//
wonItems = result ? [result] : []
}
const items = wonItems.map(data => {
const title = String((data && (data.title || data.name || data.product_name || data.reward_name)) || '未知奖励')
const image = String((data && (data.image || data.img || data.pic || data.product_image || data.reward_image)) || '')
return { title, image }
})
showFlip.value = true
try { if (flipRef.value && flipRef.value.reset) flipRef.value.reset() } catch (_) {}
setTimeout(() => {
if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults(items)
}, 100)
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
fetchWinRecords(id)
}
})
function closeFlip() { showFlip.value = false }
</script>
<style lang="scss" scoped>
/* ============================================
一番赏页面 - 高级设计重构 (SCSS Integration)
============================================ */
.page-wrapper {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
}
.orb-1 {
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.2), transparent 70%);
top: -200rpx;
left: -200rpx;
animation: float 10s ease-in-out infinite;
}
.orb-2 {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($accent-gold, 0.2), transparent 70%);
bottom: 20%;
right: -100rpx;
animation: float 12s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
/* 顶部背景 */
.page-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 700rpx; /* 加高背景区域 */
z-index: 1;
}
.bg-image {
width: 100%;
height: 100%;
filter: blur(30rpx) brightness(0.9); /* 降低亮度提升文字对比度 */
transform: scale(1.1); /* 防止模糊边缘 */
}
.bg-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba($bg-page, 0.2) 0%, $bg-page 90%, $bg-page 100%);
}
.main-scroll {
position: relative;
z-index: 2;
height: 100vh;
}
/* 头部卡片 */
.header-card {
margin: $spacing-xl $spacing-lg;
background: rgba($bg-card, 0.85);
backdrop-filter: blur(24rpx);
border-radius: $radius-xl;
padding: $spacing-lg;
display: flex;
align-items: center;
box-shadow: $shadow-card;
border: 1rpx solid rgba(255, 255, 255, 0.6);
position: relative;
overflow: hidden;
/* 光泽效果 */
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2rpx;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.8), transparent);
}
}
.header-cover {
width: 180rpx;
height: 180rpx;
border-radius: $radius-md;
margin-right: $spacing-lg;
background: $bg-secondary;
box-shadow: $shadow-md;
flex-shrink: 0;
}
.header-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
height: 180rpx;
}
.header-title {
font-size: $font-xl;
font-weight: 800;
color: $text-main;
margin-bottom: $spacing-xs;
line-height: 1.3;
@include text-ellipsis(2);
}
.header-price-row {
display: flex;
align-items: baseline;
color: $brand-primary;
margin-bottom: $spacing-sm;
text-shadow: 0 2rpx 4rpx rgba($brand-primary, 0.1);
}
.price-symbol { font-size: $font-md; font-weight: 700; }
.price-num { font-size: $font-xxl; font-weight: 900; margin: 0 4rpx; font-family: 'DIN Alternate', sans-serif; }
.price-unit { font-size: $font-sm; color: $text-sub; margin-left: 4rpx; }
.header-tags {
display: flex;
gap: $spacing-xs;
flex-wrap: wrap;
}
.tag-item {
font-size: $font-xs;
color: $brand-primary-dark;
background: rgba($brand-primary, 0.08);
padding: 4rpx $spacing-sm;
border-radius: $radius-sm;
font-weight: 600;
border: 1rpx solid rgba($brand-primary, 0.1);
}
.header-actions {
display: flex;
flex-direction: column;
gap: $spacing-lg;
margin-left: 20rpx;
padding-left: $spacing-lg;
border-left: 1rpx solid rgba(0,0,0,0.06);
justify-content: center;
height: 140rpx;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
font-size: $font-xs;
color: $text-sub;
transition: all 0.2s;
&:active {
transform: scale(0.9);
color: $text-main;
}
}
.action-btn .icon {
font-size: $font-xl;
margin-bottom: 6rpx;
filter: grayscale(0.2);
}
/* 通用板块容器 */
.section-container {
margin: 0 $spacing-lg $spacing-lg;
background: rgba(255, 255, 255, 0.9); /* 略微透明 */
border-radius: $radius-xl;
padding: $spacing-lg;
box-shadow: $shadow-sm;
backdrop-filter: blur(10rpx);
}
/* 板块标题 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding: 0 4rpx;
}
.section-title {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
position: relative;
padding-left: $spacing-lg;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8rpx;
height: 28rpx;
background: $gradient-brand;
border-radius: 4rpx;
}
}
.section-more {
font-size: $font-sm;
color: $text-tertiary;
display: flex;
align-items: center;
&::after {
content: '>';
font-family: monospace;
margin-left: 6rpx;
font-weight: 700;
}
}
/* 奖品概览 */
.preview-scroll {
white-space: nowrap;
margin: 0 -$spacing-lg; /* 负边距抵消padding */
padding: 0 $spacing-lg;
width: calc(100% + 40rpx);
}
.preview-item {
display: inline-block;
width: 200rpx;
margin-right: $spacing-lg;
vertical-align: top;
position: relative;
transition: transform 0.2s;
&:active {
transform: scale(0.96);
}
&:last-child {
margin-right: 40rpx;
}
}
.preview-img {
width: 200rpx;
height: 200rpx;
border-radius: $radius-lg;
background: $bg-secondary;
margin-bottom: $spacing-md;
box-shadow: $shadow-sm;
border: 1rpx solid rgba(0,0,0,0.03);
}
.preview-name {
font-size: $font-sm;
color: $text-secondary;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font-weight: 500;
}
.prize-tag {
position: absolute;
top: 10rpx;
left: 10rpx;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: $font-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-sm;
z-index: 10;
font-weight: 700;
backdrop-filter: blur(4rpx);
transform: scale(0.9);
transform-origin: top left;
}
.prize-tag.tag-boss {
background: $gradient-brand;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.4);
}
/* 选号区容器 */
.selector-container {
min-height: 800rpx;
display: flex;
flex-direction: column;
background: rgba($bg-card, 0.95);
backdrop-filter: blur(20rpx);
}
/* 期号头部 */
.issue-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
background: $bg-grey;
border-radius: $radius-round; /* 胶囊形 */
padding: 10rpx;
border: 1rpx solid $border-color-light;
}
.issue-switch-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background: $bg-card;
border-radius: 50%;
box-shadow: $shadow-sm;
transition: all 0.2s;
color: $text-secondary;
&:active {
transform: scale(0.9);
background: $bg-secondary;
color: $brand-primary;
}
}
.arrow {
font-size: $font-sm;
font-weight: 800;
}
.issue-info-center {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.issue-current-text {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
}
.issue-status-badge {
font-size: $font-xs;
color: $uni-color-success;
background: rgba($uni-color-success, 0.1);
padding: 2rpx $spacing-md;
border-radius: $radius-round;
margin-top: 4rpx;
font-weight: 600;
}
.selector-body {
flex: 1;
}
/* 翻牌弹窗 */
.flip-overlay { position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 10000; }
.flip-mask {
position: absolute; left: 0; right: 0; top: 0; bottom: 0;
background: rgba(0,0,0,0.75);
backdrop-filter: blur(10rpx);
z-index: 1;
animation: fadeIn 0.3s ease-out;
}
.flip-content {
position: relative; display: flex; flex-direction: column; height: 100%; padding: 40rpx; z-index: 2;
animation: scaleIn 0.3s ease-out;
}
.overlay-close {
background: rgba(255,255,255,0.2) !important;
color: #FFFFFF !important;
border-radius: 999rpx;
align-self: center;
margin-top: 40rpx;
font-weight: 600;
border: 1rpx solid rgba(255,255,255,0.3);
padding: 10rpx 60rpx;
font-size: 30rpx;
backdrop-filter: blur(10rpx);
&:active {
background: rgba(255,255,255,0.3) !important;
}
}
/* 动画定义 */
.animate-enter {
animation: fadeInUp 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(40rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
</style>

View File

@ -1,270 +0,0 @@
<template>
<view class="wrap">
<view class="header">
<button class="add" @click="toAdd">新增地址</button>
</view>
<view v-if="error" class="error">{{ error }}</view>
<view v-if="list.length === 0 && !loading" class="empty">暂无地址</view>
<view v-for="item in list" :key="item.id" class="addr">
<view class="addr-main">
<view class="addr-row">
<text class="name">姓名{{ item.name || item.realname }}</text>
</view>
<view class="addr-row">
<text class="phone">手机号{{ item.phone || item.mobile }}</text>
</view>
<view class="addr-row" v-if="item.is_default">
<text class="default">默认</text>
</view>
<view class="addr-row">
<text class="region">省市区{{ item.province }}{{ item.city }}{{ item.district }}</text>
</view>
<view class="addr-row">
<text class="detail">详细地址{{ item.address || item.detail }}</text>
</view>
</view>
<view class="addr-actions">
<button size="mini" @click="toEdit(item)">编辑</button>
<button size="mini" type="warn" @click="onDelete(item)">删除</button>
<button size="mini" :disabled="item.is_default" @click="onSetDefault(item)">设为默认</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { listAddresses, deleteAddress, setDefaultAddress } from '../../api/appUser'
const list = ref([])
const loading = ref(false)
const error = ref('')
async function fetchList() {
const user_id = uni.getStorageSync('user_id')
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!user_id || !token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return
}
loading.value = true
error.value = ''
try {
const data = await listAddresses(user_id)
list.value = Array.isArray(data) ? data : (data && (data.list || data.items)) || []
} catch (e) {
error.value = e && (e.message || e.errMsg) || '获取地址失败'
} finally {
loading.value = false
}
}
function toAdd() {
uni.removeStorageSync('edit_address')
uni.navigateTo({ url: '/pages/address/edit' })
}
function toEdit(item) {
uni.setStorageSync('edit_address', item)
uni.navigateTo({ url: `/pages/address/edit?id=${item.id}` })
}
function onDelete(item) {
const user_id = uni.getStorageSync('user_id')
uni.showModal({
title: '确认删除',
content: '确定删除该地址吗?',
success: async (res) => {
if (res.confirm) {
try {
await deleteAddress(user_id, item.id)
fetchList()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
async function onSetDefault(item) {
try {
const user_id = uni.getStorageSync('user_id')
await setDefaultAddress(user_id, item.id)
fetchList()
} catch (e) {
uni.showToast({ title: '设置失败', icon: 'none' })
}
}
onLoad(() => {
fetchList()
})
</script>
<style lang="scss" scoped>
/* ============================================
奇盒潮玩 - 地址管理页面
采用暖橙色调的卡片列表设计
============================================ */
.wrap {
padding: $spacing-md;
min-height: 100vh;
background-color: $bg-page;
}
.header {
display: flex;
justify-content: flex-end;
margin-bottom: $spacing-lg;
}
.add {
font-size: $font-md;
background: $gradient-brand !important;
color: #FFFFFF !important;
border-radius: $radius-round;
padding: 0 $spacing-xl;
height: 72rpx;
line-height: 72rpx;
font-weight: 600;
box-shadow: $shadow-warm;
}
.add:active {
transform: scale(0.96);
}
/* 地址卡片 */
.addr {
background: #FFFFFF;
border-radius: $radius-md;
padding: $spacing-lg;
margin-bottom: $spacing-md;
box-shadow: $shadow-sm;
animation: fadeInUp 0.4s ease-out backwards;
}
@for $i from 1 through 10 {
.addr:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
}
}
.addr-main {
margin-bottom: $spacing-md;
}
.addr-row {
display: flex;
align-items: center;
margin-bottom: $spacing-sm;
}
.addr-row:last-child {
margin-bottom: 0;
}
.name {
font-size: $font-lg;
font-weight: 600;
color: $text-main;
}
.phone {
font-size: $font-md;
color: $text-sub;
}
.default {
font-size: $font-xs;
color: #FFFFFF;
background: $gradient-brand;
padding: 4rpx $spacing-sm;
border-radius: $radius-round;
font-weight: 500;
}
.region {
font-size: $font-sm;
color: $text-sub;
}
.detail {
font-size: $font-md;
color: $text-main;
line-height: 1.5;
}
/* 操作按钮 */
.addr-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-md;
margin-top: $spacing-lg;
padding-top: $spacing-lg;
border-top: 1rpx solid $border-color-light;
}
.addr-actions button {
font-size: $font-sm;
height: 52rpx;
line-height: 52rpx;
padding: 0 $spacing-lg;
border-radius: $radius-round;
margin: 0;
font-weight: 600;
border: none;
background: $bg-secondary;
color: $text-main;
&::after { border: none; }
&:active {
transform: scale(0.96);
background: darken($bg-secondary, 5%);
}
}
.addr-actions button[type="warn"] {
background: rgba($color-error, 0.1) !important;
color: $color-error !important;
}
.addr-actions button:not([type]) {
background: $bg-secondary !important;
color: $text-main !important;
}
/* 空状态 */
.empty {
text-align: center;
color: $text-sub;
margin-top: 120rpx;
font-size: $font-md;
}
/* 错误提示 */
.error {
color: $color-error;
font-size: $font-sm;
margin-bottom: $spacing-md;
padding: $spacing-md;
background: rgba($color-error, 0.1);
border-radius: $radius-md;
text-align: center;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -1,7 +1,18 @@
<template>
<view class="wrap">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<!-- 自定义 tabBar -->
<!-- #ifdef MP-TOUTIAO -->
<customTabBarToutiao />
<!-- #endif -->
<!-- #ifndef MP-TOUTIAO -->
<customTabBar />
<!-- #endif -->
<!-- 顶部 Tab -->
<view class="tabs">
<view class="tabs glass-card">
<view class="tab-item" :class="{ active: currentTab === 0 }" @tap="switchTab(0)">
<text class="tab-text">待处理</text>
<text class="tab-count" v-if="aggregatedList.length > 0">({{ aggregatedList.length }})</text>
@ -35,6 +46,7 @@
<text class="item-name">{{ item.name || '未命名道具' }}</text>
<text class="item-price" v-if="item.price">单价: ¥{{ item.price }}</text>
<view class="item-actions">
<text class="invite-btn" v-if="!item.selected" @tap.stop="onInvite(item)">邀请填写</text>
<text class="item-count" v-if="!item.selected">x{{ item.count || 1 }}</text>
<view class="stepper" v-else @tap.stop>
<text class="step-btn minus" @tap.stop="changeCount(item, -1)">-</text>
@ -50,7 +62,9 @@
<!-- 底部操作栏 -->
<view class="bottom-bar" v-if="hasSelected">
<view class="selected-info">已选 {{ totalSelectedCount }} </view>
<view class="selected-info">
<text>已选 {{ totalSelectedCount }} </text>
</view>
<view class="btn-group">
<button class="action-btn btn-ship" @tap="onShip">发货</button>
<button class="action-btn btn-redeem" @tap="onRedeem">兑换</button>
@ -73,8 +87,11 @@
<text class="batch-no" v-if="item.batch_no">{{ item.batch_no }}</text>
<view class="count-badge">{{ item.count }}件商品</view>
</view>
<view class="shipment-status" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
<view class="shipment-actions">
<view class="shipment-status" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
</view>
<text class="shipment-cancel" v-if="Number(item.status) === 1 && item.batch_no" @tap="onCancelShipping(item)">撤销发货</text>
</view>
</view>
@ -126,21 +143,57 @@
<view v-if="loading && shippedList.length > 0" class="loading-more">加载更多...</view>
<view v-if="!hasMore && shippedList.length > 0" class="no-more">没有更多了</view>
</block>
<!-- 分享弹窗 -->
<view class="share-mask" v-if="showSharePopup" @tap="showSharePopup = false" @touchmove.stop></view>
<view class="share-popup glass-card" :class="{ 'show': showSharePopup }">
<view class="share-header">
<text class="share-title">邀请好友填写地址</text>
<text class="share-close" @tap="showSharePopup = false">×</text>
</view>
<view class="share-body">
<view class="share-item-preview">
<image class="preview-img" :src="sharingItem.image" mode="aspectFit"></image>
<view class="preview-info">
<text class="preview-name">{{ sharingItem.name }}</text>
<text class="preview-desc">邀请好友填写地址后该奖品将发货至好友手中并认领归属于分享账号</text>
</view>
</view>
<view class="share-actions">
<!-- #ifdef MP-WEIXIN -->
<button class="action-btn share-card-btn" open-type="share">发送给微信好友</button>
<!-- #endif -->
<button class="action-btn copy-link-btn" @tap="onCopyShareLink">复制分享链接</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { getInventory, getProductDetail, redeemInventory, requestShipping, listAddresses, getShipments } from '@/api/appUser'
import { onShow, onReachBottom, onShareAppMessage, onPullDownRefresh } from '@dcloudio/uni-app'
import { getInventory, getProductDetail, redeemInventory, requestShipping, cancelShipping, listAddresses, getShipments, createAddressShare } from '@/api/appUser'
import { vibrateShort } from '@/utils/vibrate.js'
import { checkPhoneBound, checkPhoneBoundSync } from '@/utils/checkPhone.js'
// #ifdef MP-TOUTIAO
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
// #endif
// #ifndef MP-TOUTIAO
import customTabBar from '@/components/app-tab-bar.vue'
// #endif
const currentTab = ref(0)
const aggregatedList = ref([])
const shippedList = ref([])
const showSharePopup = ref(false)
const sharingItem = ref({})
const currentShareToken = ref('')
const currentShortLink = ref('')
const loading = ref(false)
const page = ref(1)
const pageSize = ref(100)
const hasMore = ref(true)
const productMetaCache = new Map()
const totalCount = computed(() => {
return aggregatedList.value.reduce((sum, item) => sum + (item.count || 1), 0)
@ -158,15 +211,42 @@ const isAllSelected = computed(() => {
return aggregatedList.value.length > 0 && aggregatedList.value.every(item => item.selected)
})
onShow(() => {
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
console.log('cabinet onShow token:', token, 'isLogin:', !!token, 'phoneBound:', phoneBound)
async function fetchProductMeta(productId) {
const key = String(productId || '').trim()
if (!key) return null
if (productMetaCache.has(key)) return productMetaCache.get(key)
const res = await getProductDetail(productId)
const p = res && (res.data ?? res.result ?? res)
const meta = {
price: null
}
const rawPrice = (p && (p.price_sale ?? p.price)) ?? (res && res.price)
if (rawPrice !== undefined && rawPrice !== null) {
const n = Number(rawPrice)
if (!Number.isNaN(n)) meta.price = n / 100
}
productMetaCache.set(key, meta)
return meta
}
if (!token || !phoneBound) {
onShow(() => {
//
if (!checkPhoneBoundSync()) return
// Check for external tab switch request
try {
const targetTab = uni.getStorageSync('cabinet_target_tab')
if (targetTab !== '' && targetTab !== null && targetTab !== undefined) {
currentTab.value = Number(targetTab)
uni.removeStorageSync('cabinet_target_tab')
}
} catch (e) {}
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
@ -186,7 +266,21 @@ onShow(() => {
if (currentTab.value === 1) {
loadShipments(uid)
} else {
loadAllInventory(uid)
loadInventory(uid) // onReachBottom
}
})
onPullDownRefresh(() => {
const uid = uni.getStorageSync("user_id")
page.value = 1
hasMore.value = true
// Reset lists
if (currentTab.value === 1) {
shippedList.value = []
loadShipments(uid).finally(() => uni.stopPullDownRefresh())
} else {
aggregatedList.value = []
loadInventory(uid).finally(() => uni.stopPullDownRefresh())
}
})
@ -202,6 +296,7 @@ onReachBottom(() => {
})
function switchTab(index) {
if (loading.value) return //
currentTab.value = index
//
page.value = 1
@ -212,7 +307,7 @@ function switchTab(index) {
if (currentTab.value === 1) {
loadShipments(uid)
} else {
loadAllInventory(uid)
loadInventory(uid) //
}
}
@ -268,7 +363,8 @@ function getStatusClass(status) {
1: 'status-pending', //
2: 'status-shipped', //
3: 'status-delivered', //
4: 'status-cancelled' //
4: 'status-abnormal', //
5: 'status-cancelled' //
}
return statusMap[status] || 'status-pending'
}
@ -278,7 +374,8 @@ function getStatusText(status) {
1: '待发货',
2: '运输中',
3: '已签收',
4: '已取消'
4: '异常',
5: '已取消'
}
return statusMap[status] || '待发货'
}
@ -349,7 +446,7 @@ async function loadShipments(uid) {
const next = page.value === 1 ? mapped : [...shippedList.value, ...mapped]
shippedList.value = next
if (list.length < pageSize.value || (page.value * pageSize.value >= total && total > 0)) { hasMore.value = false } else { page.value += 1 }
if (page.value * pageSize.value >= total && total > 0) { hasMore.value = false } else { page.value += 1 }
if (list.length === 0) { hasMore.value = false }
} catch (e) {
console.error('Load shipments error:', e)
@ -363,7 +460,11 @@ async function loadInventory(uid) {
if (loading.value) return
loading.value = true
try {
const res = await getInventory(uid, page.value, pageSize.value)
const params = {}
if (currentTab.value === 0) {
params.status = 1
}
const res = await getInventory(uid, page.value, pageSize.value, params)
console.log('Inventory loaded:', res)
let list = []
@ -380,137 +481,37 @@ async function loadInventory(uid) {
total = res.length
}
// status=1 () status=3 (使//)
// status=1:
// status=3:
const filteredList = list.filter(item => {
const s = Number(item.status)
return s === 1 || s === 3
})
// status items
const nextList = page.value === 1 ? [] : (currentTab.value === 1 ? [...shippedList.value] : [...aggregatedList.value])
//
if (filteredList.length > 0) {
console.log('Debug Inventory Item:', filteredList[0])
}
// Tab
const targetItems = filteredList.filter(item => {
// Tab 0: (has_shipment false status=1)
// Tab 1: (has_shipment true)
// API has_shipment true/false 1/0 "true"/"false"
//
const isShipped = item.has_shipment === true || item.has_shipment === 1 || String(item.has_shipment) === 'true' || String(item.has_shipment) === '1'
if (currentTab.value === 1) {
//
// status=3 has_shipment=true
return isShipped
} else {
// status=1 (status=3 )
return !isShipped && Number(item.status) === 1
}
})
console.log('Filtered list (status=1, tab=' + currentTab.value + '):', targetItems)
//
const newItems = targetItems.map(item => {
let imageUrl = ''
try {
let rawImg = item.product_images || item.image
if (rawImg && typeof rawImg === 'string') {
imageUrl = cleanUrl(rawImg)
}
} catch (e) {
console.error('Image parse error:', e)
}
const isShipped = item.has_shipment === true || item.has_shipment === 1 || String(item.has_shipment) === 'true' || String(item.has_shipment) === '1'
return {
id: item.product_id || item.id, // 使 product_id
original_ids: [item.id], // id
name: (item.product_name || item.name || '').trim(),
list.forEach(item => {
let imageUrl = cleanUrl(item.product_images || item.image)
const mappedItem = {
id: item.product_id,
original_ids: item.inventory_ids || [],
name: (item.product_name || '未知商品').trim(),
image: imageUrl,
count: 1,
price: item.product_price ? item.product_price / 100 : null,
count: item.count || 0,
selected: false,
selectedCount: 1,
has_shipment: isShipped,
updated_at: item.updated_at //
selectedCount: item.count || 0,
has_shipment: item.has_shipment,
updated_at: item.updated_at
}
nextList.push(mappedItem)
})
console.log('Mapped new items:', newItems.length)
//
// 1. newItems
// 2. newItems
//
let currentList = currentTab.value === 1 ? shippedList : aggregatedList
let next = page.value === 1 ? [] : [...currentList.value]
console.log('Final list (tab=' + currentTab.value + '):', JSON.parse(JSON.stringify(nextList)))
if (currentTab.value === 1) {
// updated_at
// ID
// UI computed
// flat list updated_at + product_id
// updated_at
// updated_at product_id
newItems.forEach(newItem => {
// updated_at product_id
// updated_at ISO
const existingItem = next.find(i =>
i.id == newItem.id &&
new Date(i.updated_at).getTime() === new Date(newItem.updated_at).getTime()
)
if (existingItem) {
existingItem.count += 1
if (Array.isArray(existingItem.original_ids)) {
existingItem.original_ids.push(...newItem.original_ids)
}
} else {
next.push(newItem)
}
})
shippedList.value = nextList
} else {
// product_id (id)
newItems.forEach(newItem => {
if (!newItem.id) {
next.push(newItem)
return
}
const existingItem = next.find(i => i.id == newItem.id)
if (existingItem) {
existingItem.count += 1
if (Array.isArray(existingItem.original_ids)) {
existingItem.original_ids.push(...newItem.original_ids)
} else {
existingItem.original_ids = [...newItem.original_ids]
}
} else {
next.push(newItem)
}
})
}
console.log('Final aggregated list:', JSON.parse(JSON.stringify(next)))
if (currentTab.value === 1) {
shippedList.value = next
} else {
aggregatedList.value = next
aggregatedList.value = nextList
}
//
// total status=1
//
if (list.length < pageSize.value || (page.value * pageSize.value >= total && total > 0)) {
// total
if ((page.value * pageSize.value >= total && total > 0) || list.length === 0) {
hasMore.value = false
} else {
page.value += 1
@ -529,50 +530,34 @@ async function loadInventory(uid) {
}
}
async function loadAllInventory(uid) {
try {
while (hasMore.value) {
await loadInventory(uid)
}
fetchProductPrices()
} catch (e) {}
}
async function fetchProductPrices() {
const currentList = currentTab.value === 1 ? shippedList : aggregatedList
const list = currentList.value
for (let i = 0; i < list.length; i++) {
const item = list[i]
if (item.id && !item.price) {
try {
const res = await getProductDetail(item.id)
if (res && (res.price !== undefined || res.data?.price !== undefined)) {
// res.price res.data.price ()
const raw = res.price !== undefined ? res.price : res.data?.price
const num = Number(raw)
item.price = isNaN(num) ? null : (num / 100)
}
} catch (e) {
console.error('Fetch price failed for:', item.id, e)
}
}
}
}
function toggleSelect(item) {
vibrateShort()
item.selected = !item.selected
if (item.selected) {
//
item.selectedCount = item.count
if (!item.price && item.id) {
fetchProductMeta(item.id).then(meta => {
if (!meta) return
if (!item.price && meta.price !== null) item.price = meta.price
}).catch(() => {})
}
}
}
function toggleSelectAll() {
vibrateShort()
const newState = !isAllSelected.value
aggregatedList.value.forEach(item => {
item.selected = newState
if (newState) {
item.selectedCount = item.count
if (!item.price && item.id) {
fetchProductMeta(item.id).then(meta => {
if (!meta) return
if (!item.price && meta.price !== null) item.price = meta.price
}).catch(() => {})
}
}
})
}
@ -586,6 +571,7 @@ function changeCount(item, delta) {
}
async function onRedeem() {
vibrateShort()
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
@ -621,7 +607,7 @@ async function onRedeem() {
aggregatedList.value = []
page.value = 1
hasMore.value = true
loadAllInventory(user_id)
loadInventory(user_id)
} catch (e) {
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
} finally {
@ -633,6 +619,7 @@ async function onRedeem() {
}
async function onShip() {
vibrateShort()
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
@ -653,7 +640,33 @@ async function onShip() {
return
}
// 2.
// 1.
try {
const addresses = await listAddresses(user_id)
const addressList = addresses.list || addresses.data || addresses || []
if (!addressList || addressList.length === 0) {
//
uni.showModal({
title: '提示',
content: '申请发货需要设置默认地址,是否前往新建地址?',
confirmText: '前往',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages-user/address/edit' })
}
}
})
return
}
} catch (e) {
console.error('获取地址列表失败:', e)
uni.showToast({ title: '获取地址失败', icon: 'none' })
return
}
// 2.
uni.showModal({
title: '确认发货',
content: `${allIds.length} 件物品,确认申请发货?`,
@ -668,7 +681,7 @@ async function onShip() {
aggregatedList.value = []
page.value = 1
hasMore.value = true
loadAllInventory(user_id)
loadInventory(user_id)
} catch (e) {
uni.showToast({ title: e.message || '申请失败', icon: 'none' })
} finally {
@ -678,11 +691,100 @@ async function onShip() {
}
})
}
function onCancelShipping(shipment) {
const user_id = uni.getStorageSync('user_id')
const batchNo = shipment && shipment.batch_no
if (!user_id || !batchNo) return
uni.showModal({
title: '撤销发货',
content: `确认不再发货,并撤销发货单 ${batchNo} 吗?`,
confirmText: '确认撤销',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await cancelShipping(user_id, batchNo)
uni.showToast({ title: '已撤销发货', icon: 'success' })
page.value = 1
hasMore.value = true
shippedList.value = []
await loadShipments(user_id)
} catch (e) {
uni.showToast({ title: e?.message || '取消失败', icon: 'none' })
} finally {
}
}
})
}
//
onShareAppMessage((res) => {
showSharePopup.value = false
return {
title: `送你一个好礼,快来填写地址领走吧!`,
path: `/pages-user/address/submit?token=${currentShareToken.value}`,
imageUrl: sharingItem.value.image || '/static/logo.png'
}
})
async function onInvite(item) {
vibrateShort()
const user_id = uni.getStorageSync('user_id')
if (!user_id) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
// inventory id
const invId = item.original_ids && item.original_ids[0]
if (!invId) {
uni.showToast({ title: '无效的资产', icon: 'none' })
return
}
uni.showLoading({ title: '准备分享...' })
try {
const res = await createAddressShare(user_id, invId)
//
currentShareToken.value = res.data?.share_token || res.share_token
currentShortLink.value = res.data?.short_link || res.short_link || ''
//
sharingItem.value = {
id: invId,
name: item.name,
image: item.image,
count: item.count
}
//
showSharePopup.value = true
} catch (e) {
uni.showToast({ title: e.message || '生成失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
function onCopyShareLink() {
let url = currentShortLink.value
if (!url) {
url = `${window?.location?.origin || ''}/pages-user/address/submit?token=${currentShareToken.value}`
}
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '已复制链接', icon: 'success' })
showSharePopup.value = false
}
})
}
</script>
<style lang="scss" scoped>
/* ============================================
奇盒潮玩 - 货柜页面
柯大鸭潮玩 - 柜页面
采用现代卡片式布局统一设计语言
============================================ */
@ -693,25 +795,28 @@ async function onShip() {
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* 顶部 Tab */
.tabs {
@extend .glass-card;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 88rpx;
background: rgba($bg-card, 0.9);
backdrop-filter: blur(20rpx);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
box-shadow: $shadow-sm;
padding: 0;
margin: 0;
border-radius: 0;
border-top: none;
border-left: none;
border-right: none;
}
.tab-item {
@ -887,9 +992,22 @@ async function onShip() {
.item-actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
}
.invite-btn {
font-size: 22rpx;
color: $brand-primary;
background: rgba($brand-primary, 0.1);
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-weight: 500;
&:active {
opacity: 0.7;
background: rgba($brand-primary, 0.2);
}
}
.item-count {
font-size: 28rpx;
color: $text-main;
@ -940,7 +1058,7 @@ async function onShip() {
align-items: center;
justify-content: space-between;
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
z-index: 99;
z-index: 1000; /* 提高z-index确保在tabbar(999)上方 */
height: auto; /* reset old height */
animation: slideUp 0.3s ease-out;
}
@ -948,6 +1066,9 @@ async function onShip() {
font-size: 28rpx;
color: $text-main;
font-weight: 600;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.btn-group {
display: flex;
@ -1005,6 +1126,12 @@ async function onShip() {
padding-bottom: 20rpx;
border-bottom: 1rpx solid rgba(0,0,0,0.05);
}
.shipment-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
}
.shipment-batch {
display: flex;
align-items: center;
@ -1038,6 +1165,13 @@ async function onShip() {
&.status-delivered { background: #F6FFED; color: #52C41A; }
&.status-cancelled { background: #F5F5F5; color: #999; }
}
.shipment-cancel {
font-size: 22rpx;
color: $text-sub;
padding: 6rpx 14rpx;
border-radius: 20rpx;
background: rgba(0,0,0,0.04);
}
.product-thumbnails {
margin-bottom: 24rpx;
@ -1148,12 +1282,124 @@ async function onShip() {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.bottom-spacer {
height: 120rpx;
}
.share-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 998;
backdrop-filter: blur(4rpx);
}
.share-popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 40rpx 40rpx 0 0;
z-index: 999;
padding: 40rpx;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
&.show {
transform: translateY(0);
}
}
.share-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40rpx;
.share-title {
font-size: 32rpx;
font-weight: 700;
color: $text-main;
}
.share-close {
font-size: 40rpx;
color: $text-sub;
padding: 10rpx;
}
}
.share-item-preview {
display: flex;
background: #f8f9fa;
padding: 30rpx;
border-radius: 24rpx;
margin-bottom: 50rpx;
.preview-img {
width: 120rpx;
height: 120rpx;
border-radius: 16rpx;
margin-right: 20rpx;
background: #fff;
}
.preview-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.preview-name {
font-size: 28rpx;
font-weight: 600;
color: $text-main;
margin-bottom: 8rpx;
}
.preview-desc {
font-size: 20rpx;
color: $text-sub;
line-height: 1.4;
}
}
}
.share-actions {
display: flex;
flex-direction: column;
gap: 20rpx;
.action-btn {
height: 90rpx;
border-radius: 45rpx;
font-size: 28rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
border: none;
&::after { border: none; }
&.share-card-btn {
background: #07c160;
color: #fff;
}
&.copy-link-btn {
background: #f0f0f0;
color: $text-main;
}
&:active {
opacity: 0.8;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,156 +0,0 @@
<template>
<view class="container">
<image class="logo" src="/static/logo.png" mode="widthFix"></image>
<view class="title">注册新账号</view>
<view class="form">
<view class="input-row">
<text class="label">账号</text>
<input type="text" v-model="account" class="input-field" placeholder="请输入账号" />
</view>
<view class="input-row">
<text class="label">密码</text>
<input type="password" v-model="password" class="input-field" placeholder="请输入密码" />
</view>
<view class="input-row">
<text class="label">确认密码</text>
<input type="password" v-model="confirmPassword" class="input-field" placeholder="请再次输入密码" />
</view>
<button class="btn submit-btn" :disabled="loading" @click="onRegister">注册</button>
</view>
<view class="login-link">
<text @tap="goLogin">已有账号去登录</text>
</view>
<view v-if="error" class="error">{{ error }}</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const account = ref('')
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const error = ref('')
function goLogin() {
uni.navigateBack()
}
function onRegister() {
if (!account.value || !password.value) {
uni.showToast({ title: '请填写完整', icon: 'none' })
return
}
if (password.value !== confirmPassword.value) {
uni.showToast({ title: '两次密码不一致', icon: 'none' })
return
}
// TODO: API
uni.showToast({ title: '注册功能开发中', icon: 'none' })
}
</script>
<style scoped>
/* ============================================
奇盒潮玩 - 注册页面
============================================ */
.container {
min-height: 100vh;
padding: 60rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
background: linear-gradient(180deg, #FFF8F3 0%, #FFE8D1 50%, #FFDAB9 100%);
}
.logo {
width: 160rpx;
height: 160rpx;
margin-top: 80rpx;
margin-bottom: 32rpx;
border-radius: 32rpx;
box-shadow: 0 12rpx 36rpx rgba(255, 107, 53, 0.2);
}
.title {
font-size: 40rpx;
font-weight: 700;
color: #1F2937;
margin-bottom: 48rpx;
}
.form {
width: 100%;
max-width: 600rpx;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border-radius: 32rpx;
padding: 40rpx;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.08);
}
.input-row {
display: flex;
align-items: center;
margin-bottom: 24rpx;
background: #F9FAFB;
border-radius: 16rpx;
padding: 8rpx 24rpx;
border: 2rpx solid #E5E7EB;
}
.label {
width: 140rpx;
font-size: 28rpx;
font-weight: 500;
color: #6B7280;
}
.input-field {
flex: 1;
height: 80rpx;
border: none;
background: transparent;
font-size: 28rpx;
color: #1F2937;
}
.submit-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
margin-top: 32rpx;
background: linear-gradient(135deg, #FF9F43, #FF6B35) !important;
color: #FFFFFF !important;
border: none;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 600;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.35);
}
.submit-btn:active {
transform: scale(0.97);
}
.login-link {
margin-top: 32rpx;
font-size: 26rpx;
color: #FF9F43;
font-weight: 500;
}
.error {
margin-top: 24rpx;
color: #EF4444;
font-size: 26rpx;
text-align: center;
}
</style>

View File

@ -1,163 +0,0 @@
<template>
<view class="page">
<view class="bg-decoration"></view>
<view class="loading" v-if="loading">加载中...</view>
<view v-else-if="detail.id" class="detail-wrap">
<image v-if="detail.main_image" class="main-image" :src="detail.main_image" mode="widthFix" />
<view class="info-card">
<view class="title">{{ detail.title || detail.name || '-' }}</view>
<view class="price-row">
<text class="price">¥{{ formatPrice(detail.price_sale || detail.price) }}</text>
<text class="points" v-if="detail.points_required">{{ detail.points_required }}积分</text>
</view>
<view class="stock" v-if="detail.stock !== null && detail.stock !== undefined">库存{{ detail.stock }}</view>
<view class="desc" v-if="detail.description">{{ detail.description }}</view>
</view>
</view>
<view v-else class="empty">商品不存在</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getProductDetail } from '../../api/appUser'
const detail = ref({})
const loading = ref(false)
function formatPrice(p) {
if (p === undefined || p === null) return '0.00'
return (Number(p) / 100).toFixed(2)
}
async function fetchDetail(id) {
loading.value = true
try {
const res = await getProductDetail(id)
detail.value = res || {}
} catch (e) {
detail.value = {}
} finally {
loading.value = false
}
}
onLoad((opts) => {
const id = opts && opts.id
if (id) fetchDetail(id)
})
</script>
<style lang="scss" scoped>
/* ============================================
奇盒潮玩 - 商品详情页
============================================ */
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: env(safe-area-inset-bottom);
}
.loading, .empty {
text-align: center;
padding: 120rpx 40rpx;
color: $text-secondary;
font-size: $font-md;
}
.detail-wrap {
padding-bottom: 40rpx;
animation: fadeInUp 0.4s ease-out;
}
.main-image {
width: 100%;
height: 750rpx; /* Square aspect ratio */
display: block;
background: $bg-secondary;
box-shadow: $shadow-sm;
}
.info-card {
margin: $spacing-lg;
margin-top: -60rpx;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20rpx);
border-radius: $radius-xl;
padding: $spacing-xl;
box-shadow: $shadow-lg;
position: relative;
z-index: 2;
}
.title {
font-size: $font-xl;
font-weight: 800;
color: $text-main;
margin-bottom: $spacing-md;
line-height: 1.4;
}
.price-row {
display: flex;
align-items: baseline;
gap: $spacing-sm;
margin-bottom: $spacing-lg;
}
.price {
font-size: $font-xxl;
font-weight: 900;
color: $brand-primary;
font-family: 'DIN Alternate', sans-serif;
&::before {
content: '¥';
font-size: $font-md;
margin-right: 4rpx;
}
}
.points {
font-size: $font-sm;
color: $brand-primary;
padding: 6rpx $spacing-md;
background: rgba($brand-primary, 0.1);
border-radius: 100rpx;
font-weight: 600;
}
.stock {
font-size: $font-sm;
color: $text-secondary;
margin-bottom: $spacing-lg;
background: $bg-secondary;
display: inline-block;
padding: 6rpx $spacing-md;
border-radius: $radius-sm;
}
.desc {
font-size: $font-lg;
color: $text-main;
line-height: 1.8;
padding-top: $spacing-lg;
border-top: 1rpx dashed $border-color-light;
&::before {
content: '商品详情';
display: block;
font-size: $font-md;
color: $text-secondary;
margin-bottom: $spacing-sm;
font-weight: 700;
}
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40rpx); }
to { opacity: 1; transform: translateY(0); }
}
</style>

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 92 KiB

BIN
static/share_invite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

144
uni.scss
View File

@ -1,5 +1,5 @@
/**
* 奇盒潮玩 - 全局样式系统
* 柯大鸭潮玩 - 全局样式系统
*
* 基于潮玩盲盒风格的设计系统采用暖橙渐变色调
* 传递惊喜期待活力的品牌调性
@ -10,37 +10,38 @@
============================================ */
/* 主色调 - 更加活力与现代 */
$brand-primary: #FF6B00; // 核心品牌橙 (更纯粹)
$brand-secondary: #FF9500; // 次级品牌色 (用于渐变/辅助)
$brand-primary: #FF6B00; // 核心品牌橙 (更纯粹)
$brand-secondary: #FF9500; // 次级品牌色 (用于渐变/辅助)
$brand-primary-light: #FF9500; // 亮橙
$brand-primary-dark: #E65100; // 深橙
$brand-primary-dark: #E65100; // 深橙
/* 辅助色 - 丰富视觉层次 */
$accent-gold: #FFC107; // 质感金
$accent-red: #FF3B30; // 促销红
$accent-blue: #007AFF; // 科技蓝
$accent-purple: #AF52DE; // 梦幻紫
$accent-pink: #FF2D55; // 活力粉
$accent-cyan: #5AC8FA; // 清新蓝
$color-success: #34C759; // 成功色
$color-warning: #FF9F0A; // 警告色
$color-error: #FF3B30; // 错误色
$accent-gold: #FFC107; // 质感金
$accent-orange: #FF9500; // 活力橙
$accent-red: #FF3B30; // 促销红
$accent-blue: #007AFF; // 科技蓝
$accent-purple: #AF52DE; // 梦幻紫
$accent-pink: #FF2D55; // 活力粉
$accent-cyan: #5AC8FA; // 清新蓝
$color-success: #34C759; // 成功色
$color-warning: #FF9F0A; // 警告色
$color-error: #FF3B30; // 错误色
/* 中性色 - 提升阅读体验 */
$text-main: #1D1D1F; // 主要文字 (接近纯黑但柔和)
$text-sub: #86868B; // 次要文字
$text-secondary: $text-sub; // Alias for compatibility
$text-tertiary: #C7C7CC; // 辅助/占位
$text-main: #1D1D1F; // 主要文字 (接近纯黑但柔和)
$text-sub: #86868B; // 次要文字
$text-secondary: $text-sub; // Alias for compatibility
$text-tertiary: #C7C7CC; // 辅助/占位
$text-placeholder: $text-tertiary; // Alias for compatibility
$text-disabled: #D1D1D6; // 禁用状态文字
$text-inverse: #FFFFFF; // 反白文字
$text-disabled: #D1D1D6; // 禁用状态文字
$text-inverse: #FFFFFF; // 反白文字
/* 背景色 - 营造氛围 */
$bg-page: #F5F5F7; // 页面底色 (高级灰)
$bg-card: #FFFFFF; // 卡片背景
$bg-page: #F5F5F7; // 页面底色 (高级灰)
$bg-card: #FFFFFF; // 卡片背景
$bg-glass: rgba(255, 255, 255, 0.8); // 毛玻璃背景
$bg-secondary: #F8F8F8; // 次级背景
$bg-grey: #FAFAFA; // 浅灰背景
$bg-secondary: #F8F8F8; // 次级背景
$bg-grey: #FAFAFA; // 浅灰背景
/* 渐变色 - 视觉冲击力 */
// 使用 CSS 变量在 style 中定义此处保留 SCSS 变量供编译使用
@ -52,11 +53,11 @@ $gradient-purple: linear-gradient(135deg, #BF5AF2 0%, #5E5CE6 100%);
/* ============================================
🌑 暗黑/特殊主题变量 (Dark Mode Support)
============================================ */
$bg-dark: #1A1A2E; // 深邃蓝黑
$bg-dark: #1A1A2E; // 深邃蓝黑
$bg-dark-card: rgba(30, 30, 50, 0.7); // 暗色玻璃卡片
$text-dark-main: #FFFFFF; // 暗色模式主字
$text-dark-main: #FFFFFF; // 暗色模式主字
$text-dark-sub: rgba(255, 255, 255, 0.7); // 暗色模式副字
$border-dark: rgba(255, 255, 255, 0.1); // 暗色边框
$border-dark: rgba(255, 255, 255, 0.1); // 暗色边框
/* ============================================
@ -183,3 +184,94 @@ $uni-font-size-paragraph: 15px;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
}
/* ============================================
💎 核心公共 UI (Premium UI 6.0)
============================================ */
/* 1. 统一背景装饰 - 漂浮光球 */
.bg-decoration {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx;
right: -100rpx;
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx;
left: -200rpx;
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
/* 2. 毛玻璃卡片基类 */
.glass-card {
background: $bg-glass;
backdrop-filter: blur(20rpx);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: $shadow-card;
border-radius: $radius-lg;
position: relative;
z-index: 1;
}
/* 3. 通用功能按钮 */
.btn-primary {
background: $gradient-brand;
color: #fff;
font-weight: 700;
border-radius: $radius-round;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-warm;
transition: all 0.2s $ease-out;
&:active {
transform: scale(0.96);
opacity: 0.9;
}
}
.btn-secondary {
background: rgba(255, 255, 255, 0.9);
color: $text-main;
font-weight: 600;
border-radius: $radius-round;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-sm;
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.2s $ease-out;
&:active {
transform: scale(0.96);
background: #fff;
}
}

201
utils/activity.js Normal file
View File

@ -0,0 +1,201 @@
/**
* 活动相关工具函数
* yifanshang/duiduipeng/wuxianshang 页面中提取的公共逻辑
*/
/**
* 解包API返回的数据
* @param {any} list - API返回的数据
* @returns {Array} 数组
*/
export function unwrap(list) {
if (Array.isArray(list)) return list
const obj = list || {}
const data = obj.data || {}
const arr = obj.list || obj.items || data.list || data.items || data
return Array.isArray(arr) ? arr : []
}
/**
* 判断真值支持多种格式
* @param {any} v - 待判断的值
* @returns {boolean}
*/
export function truthy(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
if (!s) return false
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
}
/**
* 检测是否为BOSS奖
* @param {Object} item - 奖品对象
* @returns {boolean}
*/
export function detectBoss(item) {
const i = item || {}
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
}
/**
* 奖品等级映射 (与管理端保持一致)
*/
export const PRIZE_LEVEL_LABELS = {
1: 'S',
2: 'A',
3: 'B',
4: 'C',
5: 'D',
6: 'E',
7: 'F',
8: 'G',
9: 'H',
11: 'Last'
}
/**
* 等级数字转字母/标签
* @param {number|string} level - 等级
* @returns {string}
*/
export function levelToAlpha(level) {
if (level === 'BOSS') return 'BOSS'
const n = Number(level)
if (PRIZE_LEVEL_LABELS[n]) return PRIZE_LEVEL_LABELS[n]
if (isNaN(n) || n <= 0) return String(level || '赏')
// 兜底逻辑:如果超出定义的映射,使用 A, B, C...
return String.fromCharCode(64 + n)
}
/**
* 状态转文本
* @param {number} status - 状态码
* @returns {string}
*/
export function statusToText(status) {
if (status === 1) return '进行中'
if (status === 0) return '未开始'
if (status === 2) return '已结束'
return String(status || '')
}
/**
* 标准化期列表数据
* @param {any} list - API返回的期列表
* @returns {Array}
*/
export function normalizeIssues(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
no: i.no ?? i.index ?? i.issue_no ?? i.issue_number ?? null,
status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '')
}))
}
/**
* 标准化奖励列表数据
* @param {any} list - API返回的奖励列表
* @param {Function} cleanUrl - URL清理函数
* @returns {Array}
*/
export function normalizeRewards(list, cleanUrl = (u) => u) {
const arr = unwrap(list)
const items = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
weight: Number(i.weight) || 0,
boss: detectBoss(i),
min_score: i.min_score || 0,
level: levelToAlpha(i.prize_level ?? i.level ?? (detectBoss(i) ? 'BOSS' : '赏'))
}))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => {
const rawPercent = total > 0 ? (it.weight / total) * 100 : 0
return {
...it,
percent: parseFloat(rawPercent.toFixed(2)) // 统一保留2位小数
}
})
// 按 weight 升序排列(从小到大)
enriched.sort((a, b) => (a.weight - b.weight))
return enriched
}
/**
* 查找最新的期ID
* @param {Array} list - 期列表
* @returns {string}
*/
export function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
let maxNo = -Infinity
arr.forEach(i => {
const n = Number(i.no)
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
maxNo = n
latest = i.id
}
})
return latest || (arr[0] && arr[0].id) || ''
}
/**
* 按等级分组奖励
* @param {Array} rewards - 奖励列表
* @param {string} playType - 活动类型 ('match', 'matching' 对对碰模式其他为普通模式)
* @returns {Array} 分组后的奖励
*/
export function groupRewardsByLevel(rewards, playType = 'normal') {
const isMatchType = ['match', 'matching'].includes(playType)
const groups = {}
; (rewards || []).forEach(item => {
let level = item.level || '赏'
// 如果是对对碰(具有 min_score 且不是 BOSS则组名包含对子数
if (item.min_score > 0 && level !== 'BOSS') {
level = `${item.min_score}对子`
}
if (!groups[level]) groups[level] = []
groups[level].push(item)
})
return Object.keys(groups).sort((a, b) => {
// Last 和 BOSS 优先(仅限普通模式)
if (!isMatchType) {
if (a === 'Last' || a === 'BOSS') return -1
if (b === 'Last' || b === 'BOSS') return 1
}
// 对对碰模式:按 min_score 升序排序
if (isMatchType) {
const extractScore = (key) => {
const match = key.match(/(\d+)对子/)
return match ? parseInt(match[1], 10) : 0
}
return extractScore(a) - extractScore(b)
}
// 普通模式:分组之间按该组最小 weight 排序(升序)
const minWeightA = Math.min(...groups[a].map(item => item.weight || 0))
const minWeightB = Math.min(...groups[b].map(item => item.weight || 0))
return minWeightA - minWeightB
}).map(key => {
const levelRewards = groups[key]
// 对对碰模式:保持 min_score 升序(已在 previewRewards 排序)
// 普通模式:确保分组内的奖品按 weight 升序排列(从小到大)
if (!isMatchType) {
levelRewards.sort((a, b) => (a.weight - b.weight))
}
const total = levelRewards.reduce((sum, item) => sum + (Number(item.percent) || 0), 0)
return {
level: key,
rewards: levelRewards,
totalPercent: total.toFixed(2)
}
})
}

217
utils/cache.js Normal file
View File

@ -0,0 +1,217 @@
/**
* 缓存管理工具
*/
const REWARD_CACHE_KEY = 'reward_cache_v2' // v2: 修复概率精度问题
const MATCHING_GAME_CACHE_KEY = 'matching_game_cache_v1'
const MATCHING_GAME_TIMESTAMP_KEY = 'matching_game_last_timestamp' // 对对碰最后获取卡片数据的时间
/**
* 判断缓存是否新鲜
* @param {number} timestamp - 缓存时间戳
* @param {number} ttl - 有效期毫秒默认24小时
* @returns {boolean}
*/
export function isFresh(timestamp, ttl = 24 * 60 * 60 * 1000) {
const now = Date.now()
const v = Number(timestamp || 0)
return now - v < ttl
}
/**
* 获取奖励缓存
* @returns {Object}
*/
export function getRewardCache() {
const obj = uni.getStorageSync(REWARD_CACHE_KEY) || {}
return typeof obj === 'object' && obj ? obj : {}
}
/**
* 设置奖励缓存
* @param {string} activityId - 活动ID
* @param {string} issueId - 期ID
* @param {any} value - 缓存值
*/
export function setRewardCache(activityId, issueId, value) {
const cache = getRewardCache()
const act = cache[activityId] || {}
act[issueId] = { value, ts: Date.now() }
cache[activityId] = act
uni.setStorageSync(REWARD_CACHE_KEY, cache)
}
/**
* 获取奖励缓存项
* @param {string} activityId - 活动ID
* @param {string} issueId - 期ID
* @returns {any|null}
*/
export function getRewardCacheItem(activityId, issueId) {
const cache = getRewardCache()
const act = cache[activityId] || {}
const c = act[issueId]
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
return c.value
}
return null
}
/**
* 获取对对碰游戏缓存
* @returns {Object}
*/
export function getMatchingGameCache() {
const obj = uni.getStorageSync(MATCHING_GAME_CACHE_KEY) || {}
return typeof obj === 'object' && obj ? obj : {}
}
/**
* 读取对对碰游戏缓存项
* @param {string} activityId - 活动ID
* @param {string} issueId - 期ID
* @returns {Object|null}
*/
export function readMatchingGameCacheEntry(activityId, issueId) {
const activityKey = String(activityId || '')
const issueKey = String(issueId || '')
if (!activityKey || !issueKey) return null
const cache = getMatchingGameCache()
const act = cache[activityKey] || {}
const entry = act && act[issueKey]
const ok = entry && typeof entry === 'object' && entry.game_id
return ok ? entry : null
}
/**
* 写入对对碰游戏缓存项
* @param {string} activityId - 活动ID
* @param {string} issueId - 期ID
* @param {Object} entry - 缓存数据
*/
export function writeMatchingGameCacheEntry(activityId, issueId, entry) {
const activityKey = String(activityId || '')
const issueKey = String(issueId || '')
if (!activityKey || !issueKey) return
const cache = getMatchingGameCache()
// 清理超过170秒的缓存记录
const now = Date.now()
const TTL = 170 * 1000 // 170秒
// 遍历所有活动的所有期,删除过期的缓存
for (const actKey in cache) {
const act = cache[actKey]
if (!act || typeof act !== 'object') continue
for (const issKey in act) {
const entry = act[issKey]
if (!entry || typeof entry !== 'object') continue
const ts = Number(entry.ts || 0)
if (now - ts >= TTL) {
// 超过170秒删除此缓存记录
delete act[issKey]
}
}
// 如果该活动下没有任何期了,删除活动
if (Object.keys(act).length === 0) {
delete cache[actKey]
}
}
// 写入新的缓存
const act = (cache[activityKey] && typeof cache[activityKey] === 'object') ? cache[activityKey] : {}
act[issueKey] = entry
cache[activityKey] = act
uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache)
}
/**
* 清除对对碰游戏缓存项
* @param {string} activityId - 活动ID
* @param {string} issueId - 期ID
*/
export function clearMatchingGameCacheEntry(activityId, issueId) {
const activityKey = String(activityId || '')
const issueKey = String(issueId || '')
const cache = getMatchingGameCache()
const act = cache[activityKey]
if (!act || typeof act !== 'object') return
if (act[issueKey] !== undefined) delete act[issueKey]
if (Object.keys(act).length === 0) delete cache[activityKey]
else cache[activityKey] = act
uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache)
}
/**
* 查找最新的对对碰游戏缓存
* @param {string} activityId - 活动ID
* @returns {Object|null}
*/
export function findLatestMatchingGameCacheEntry(activityId) {
const activityKey = String(activityId || '')
if (!activityKey) return null
const cache = getMatchingGameCache()
const act = cache[activityKey]
if (!act || typeof act !== 'object') return null
let bestIssueId = ''
let bestEntry = null
let bestTs = -Infinity
Object.keys(act).forEach(issueId => {
const entry = act[issueId]
if (!entry || typeof entry !== 'object' || !entry.game_id) return
const ts = Number(entry.ts || 0)
if (!bestEntry || ts > bestTs) {
bestTs = ts
bestIssueId = issueId
bestEntry = entry
}
})
if (!bestEntry) return null
return { issue_id: bestIssueId, entry: bestEntry }
}
/**
* 记录对对碰游戏卡片获取时间戳
* 当成功调用 /api/app/matching/cards 接口时调用
*/
export function recordMatchingGameCardsTimestamp() {
const timestamp = Date.now()
uni.setStorageSync(MATCHING_GAME_TIMESTAMP_KEY, timestamp)
console.log('[MatchingGame] 记录卡片获取时间戳:', new Date(timestamp).toISOString())
}
/**
* 检查对对碰游戏缓存是否过期
* @param {number} maxAgeSeconds - 最大有效期默认100秒
* @returns {boolean} true表示未过期false表示已过期
*/
export function isMatchingGameCacheValid(maxAgeSeconds = 100) {
const timestamp = uni.getStorageSync(MATCHING_GAME_TIMESTAMP_KEY)
if (!timestamp) return false
const now = Date.now()
const ageSeconds = (now - timestamp) / 1000
const isValid = ageSeconds < maxAgeSeconds
console.log('[MatchingGame] 检查缓存有效期:', {
timestamp: new Date(timestamp).toISOString(),
now: new Date(now).toISOString(),
ageSeconds: ageSeconds.toFixed(2),
maxAgeSeconds,
isValid
})
return isValid
}
/**
* 清除对对碰游戏缓存和时间戳
*/
export function clearMatchingGameAllCache() {
uni.removeStorageSync(MATCHING_GAME_CACHE_KEY)
uni.removeStorageSync(MATCHING_GAME_TIMESTAMP_KEY)
console.log('[MatchingGame] 已清除所有对对碰缓存')
}

107
utils/checkPhone.js Normal file
View File

@ -0,0 +1,107 @@
import { getUserProfile } from '../api/appUser'
/**
* 检查用户是否已绑定手机号同步仅检查本地
* @returns {boolean} 是否已绑定手机号
*/
export function hasPhoneBound() {
// 优先检查登录方式,如果是微信手机号登录或短信登录,则已绑定手机号
const loginMethod = uni.getStorageSync('login_method')
if (loginMethod === 'wechat_phone' || loginMethod === 'sms') {
return true
}
// 降级检查 phone_number 缓存
const phoneNumber = uni.getStorageSync('phone_number') || ''
return !!phoneNumber
}
/**
* 检查手机号绑定状态
* 如果未绑定手机号,则跳转到登录页面进行绑定
* @returns {Promise<boolean>} 是否已绑定手机号
*/
export async function checkPhoneBound() {
try {
// 优先使用同步检查
if (hasPhoneBound()) {
console.log('[checkPhoneBound] 用户已通过手机号登录,跳过绑定检查')
return true
}
// 调用新的用户资料接口
const profile = await getUserProfile()
console.log('[checkPhoneBound] 用户资料:', profile)
// 检查是否已绑定手机号
const mobile = profile?.mobile
if (mobile) {
console.log('[checkPhoneBound] 已检测到手机号,允许通过:', mobile)
// 缓存手机号
uni.setStorageSync('phone_number', mobile)
return true
}
// 未绑定手机号,显示提示并跳转
console.warn('[checkPhoneBound] 未检测到手机号,提示用户绑定')
uni.showModal({
title: '需要绑定手机号',
content: '为了账号安全,请先绑定手机号',
showCancel: false,
confirmText: '去绑定',
success: () => {
uni.navigateTo({ url: '/pages/login/index?mode=sms' })
}
})
return false
} catch (err) {
console.error('[checkPhoneBound] 获取用户信息失败:', err)
// 请求失败时,降级检查本地缓存
const phoneNumber = uni.getStorageSync('phone_number') || ''
console.log('[checkPhoneBound] 降级检查本地缓存:', phoneNumber ? phoneNumber : '未找到')
if (phoneNumber) {
return true
}
// 本地也没有,提示重新登录
uni.showModal({
title: '提示',
content: '获取用户信息失败,请重新登录',
showCancel: false,
confirmText: '去登录',
success: () => {
uni.navigateTo({ url: '/pages/login/index' })
}
})
return false
}
}
/**
* 同步检查手机号绑定状态仅检查本地缓存
* @returns {boolean} 是否已绑定手机号
*/
export function checkPhoneBoundSync() {
if (hasPhoneBound()) {
console.log('[checkPhoneBoundSync] 用户已通过手机号登录,跳过绑定检查')
return true
}
const phoneNumber = uni.getStorageSync('phone_number') || ''
console.log('[checkPhoneBoundSync] 检查 phone_number 缓存:', phoneNumber ? phoneNumber : '未找到')
if (phoneNumber) {
console.log('[checkPhoneBoundSync] 已检测到手机号,允许通过:', phoneNumber)
return true
}
console.warn('[checkPhoneBoundSync] 未检测到手机号')
return false
}

76
utils/format.js Normal file
View File

@ -0,0 +1,76 @@
/**
* 格式化工具函数
*/
/**
* 清理URL字符串
* @param {string} url - 原始URL
* @returns {string} 清理后的URL
*/
export function cleanUrl(url) {
const s = String(url || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'"]/g, '').trim()
}
/**
* 格式化百分比
* @param {number} value - 百分比值
* @returns {string}
*/
export function formatPercent(value) {
const n = Number(value)
if (!Number.isFinite(n)) return '0%'
return `${n}%`
}
/**
* 格式化日期时间
* @param {string|number|Date} value - 日期值
* @returns {string}
*/
export function formatDateTime(value) {
const s = String(value || '').trim()
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}
/**
* 格式化价格分转元
* @param {number} cents -
* @param {number} decimals - 小数位数
* @returns {string}
*/
export function formatPrice(cents, decimals = 2) {
const yuan = Number(cents || 0) / 100
return yuan.toFixed(decimals)
}
/**
* 解析时间为毫秒戳
* @param {any} value - 时间值
* @returns {number|null}
*/
export function parseTimeMs(value) {
if (value === undefined || value === null || value === '') return null
if (typeof value === 'number') {
if (!Number.isFinite(value)) return null
return value < 1e12 ? value * 1000 : value
}
const s = String(value).trim()
if (!s) return null
const asNum = Number(s)
if (Number.isFinite(asNum)) return asNum < 1e12 ? asNum * 1000 : asNum
const d = new Date(s)
if (Number.isNaN(d.getTime())) return null
return d.getTime()
}

71
utils/nakama-adapter.js Normal file
View File

@ -0,0 +1,71 @@
// Nakama SDK 小程序适配器
// 将 uni-app 的 connectSocket 和 Storage 适配到 Web 标准接口
export const WebSocketAdapter = {
build: function (url) {
const socketTask = uni.connectSocket({
url: url,
complete: () => { }
});
const webSocket = {
url: url,
readyState: 0, // CONNECTING
onopen: null,
onclose: null,
onerror: null,
onmessage: null,
send: (data) => {
socketTask.send({
data: data
});
},
close: () => {
socketTask.close();
}
};
socketTask.onOpen(() => {
webSocket.readyState = 1; // OPEN
if (webSocket.onopen) {
webSocket.onopen({ type: 'open' });
}
});
socketTask.onClose((res) => {
webSocket.readyState = 3; // CLOSED
if (webSocket.onclose) {
webSocket.onclose({ code: res.code, reason: res.reason, wasClean: true });
}
});
socketTask.onError((err) => {
if (webSocket.onerror) {
webSocket.onerror({ error: err, message: err.errMsg });
}
});
socketTask.onMessage((res) => {
if (webSocket.onmessage) {
webSocket.onmessage({ data: res.data });
}
});
return webSocket;
}
};
export const localStorageAdapter = {
getItem: (key) => {
return uni.getStorageSync(key);
},
setItem: (key, value) => {
uni.setStorageSync(key, value);
},
removeItem: (key) => {
uni.removeStorageSync(key);
},
clear: () => {
uni.clearStorageSync();
}
};

5305
utils/nakama-js/nakama-js.js Normal file

File diff suppressed because it is too large Load Diff

568
utils/nakamaManager.js Normal file
View File

@ -0,0 +1,568 @@
/**
* Nakama WebSocket Manager - 小程序直连版
* 移除 SDK 依赖直接使用原生 WebSocket 协议
*/
class NakamaManager {
constructor() {
this.serverUrl = null;
this.serverKey = 'defaultkey';
this.useSSL = true;
this.host = null;
this.port = '443';
this.session = null;
this.gameToken = null;
this.socketTask = null;
this.isConnected = false;
this.isConnecting = false; // 正在连接标志位
// 消息 ID 和待处理的 Promise
this.nextCid = 1;
this.pendingRequests = {};
// 事件监听器
this.listeners = {
onmatchmakermatched: null,
onmatchdata: null,
onmatchpresence: null,
ondisconnect: null,
onerror: null
};
// 心跳定时器
this.heartbeatTimer = null;
this.heartbeatInterval = 10000; // 10秒
this.heartbeatId = 0; // 用于识别心跳版本的 ID
}
/**
* 初始化客户端配置
*/
initClient(serverUrl, serverKey = 'defaultkey') {
this.serverKey = serverKey;
this.serverUrl = serverUrl;
// 解析 URL
const isWss = serverUrl.startsWith('wss://') || serverUrl.startsWith('https://');
let host = serverUrl.replace('wss://', '').replace('ws://', '').replace('https://', '').replace('http://', '');
let port = isWss ? '443' : '7350';
if (host.includes(':')) {
const parts = host.split(':');
host = parts[0];
port = parts[1];
}
this.host = host;
this.port = port;
this.useSSL = isWss;
console.log(`[Nakama] Initialized: ${this.useSSL ? 'wss' : 'ws'}://${this.host}:${this.port}`);
}
/**
* 设置事件监听器
*/
setListeners(config) {
Object.keys(config).forEach(key => {
if (this.listeners.hasOwnProperty(key)) {
this.listeners[key] = config[key];
}
});
}
/**
* 使用 game_token 认证
*/
async authenticateWithGameToken(gameToken, externalUserId = null) {
this.gameToken = gameToken;
let customId = externalUserId;
if (!customId) {
// 获取或生成持久化的 custom ID
customId = uni.getStorageSync('nakama_custom_id');
if (!customId) {
customId = `game_${Date.now()}_${Math.random().toString(36).substring(7)}`;
uni.setStorageSync('nakama_custom_id', customId);
}
}
console.log('[Nakama] Authenticating with ID:', customId, externalUserId ? '(Account-based)' : '(Device-based)');
// HTTP 认证请求
const scheme = this.useSSL ? 'https://' : 'http://';
const portSuffix = (this.useSSL && this.port === '443') || (!this.useSSL && this.port === '80') ? '' : `:${this.port}`;
const authUrl = `${scheme}${this.host}${portSuffix}/v2/account/authenticate/custom?create=true`;
return new Promise((resolve, reject) => {
uni.request({
url: authUrl,
method: 'POST',
header: {
'Authorization': 'Basic ' + this._base64Encode(`${this.serverKey}:`),
'Content-Type': 'application/json'
},
data: { id: customId },
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.token) {
this.session = {
token: res.data.token,
refresh_token: res.data.refresh_token,
user_id: this._parseUserIdFromToken(res.data.token)
};
console.log('[Nakama] Authenticated, user_id:', this.session.user_id);
// 认证成功后建立 WebSocket 连接
this._connectWebSocket()
.then(() => resolve(this.session))
.catch(reject);
} else {
reject(new Error('Authentication failed: ' + JSON.stringify(res.data)));
}
},
fail: (err) => {
reject(new Error('Authentication request failed: ' + err.errMsg));
}
});
});
}
/**
* 建立 WebSocket 连接
*/
_connectWebSocket() {
if (this.isConnecting) {
console.log('[Nakama] Already connecting, skipping...');
return Promise.resolve();
}
return new Promise((resolve, reject) => {
// 确保清理旧连接
if (this.socketTask) {
console.log('[Nakama] Closing existing socket before new connection');
try {
this.socketTask.close();
} catch (e) {
console.warn('[Nakama] Error closing old socket:', e);
}
this.socketTask = null;
}
this.isConnecting = true;
const scheme = this.useSSL ? 'wss://' : 'ws://';
const portSuffix = (this.useSSL && this.port === '443') || (!this.useSSL && this.port === '80') ? '' : `:${this.port}`;
const wsUrl = `${scheme}${this.host}${portSuffix}/ws?lang=en&status=true&token=${encodeURIComponent(this.session.token)}`;
console.log('[Nakama] WebSocket connecting...');
this.socketTask = uni.connectSocket({
url: wsUrl,
complete: () => { }
});
const connectTimeout = setTimeout(() => {
reject(new Error('WebSocket connection timeout'));
}, 15000);
this.socketTask.onOpen(() => {
clearTimeout(connectTimeout);
this.isConnected = true;
this.isConnecting = false;
console.log('[Nakama] WebSocket connected');
this._startHeartbeat();
resolve();
});
this.socketTask.onClose((res) => {
console.log('[Nakama] WebSocket closed:', res.code, res.reason);
this.isConnected = false;
this._stopHeartbeat();
if (this.listeners.ondisconnect) {
this.listeners.ondisconnect(res);
}
});
this.socketTask.onError((err) => {
clearTimeout(connectTimeout);
console.error('[Nakama] WebSocket error:', err);
this.isConnected = false;
this.isConnecting = false;
if (this.listeners.onerror) {
this.listeners.onerror(err);
}
reject(new Error('WebSocket connection failed'));
});
this.socketTask.onMessage((res) => {
this._handleMessage(res.data);
});
});
}
/**
* 处理收到的消息
*/
_handleMessage(rawData) {
let message;
try {
message = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
} catch (e) {
console.error('[Nakama] Failed to parse message:', e);
return;
}
// 有 cid 的消息是请求的响应
if (message.cid) {
const pending = this.pendingRequests[message.cid];
if (pending) {
delete this.pendingRequests[message.cid];
clearTimeout(pending.timeout);
if (message.error) {
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
} else {
pending.resolve(message);
}
}
return;
}
// 无 cid 的消息是服务器主动推送
if (message.matchmaker_matched) {
console.log('[Nakama] Matchmaker matched:', message.matchmaker_matched.match_id);
if (this.listeners.onmatchmakermatched) {
this.listeners.onmatchmakermatched(message.matchmaker_matched);
}
} else if (message.match_data) {
// 解码 base64 数据
if (message.match_data.data) {
// 原生 Base64 -> Unit8Array
const uint8arr = this._base64ToUint8Array(message.match_data.data);
// !!!关键修复:移除不可用的 TextDecoder直接传输 Unit8Array或在业务层处理
// 由于 play.vue 中还在使用 TextDecoder这里我们需要提供一个方法来转字符串
// 为了兼容性,我们直接在这里转成字符串传出去,修改 onmatchdata 的约定
message.match_data.data = this._utf8Decode(uint8arr);
}
message.match_data.op_code = parseInt(message.match_data.op_code);
if (this.listeners.onmatchdata) {
this.listeners.onmatchdata(message.match_data);
}
} else if (message.match_presence_event) {
if (this.listeners.onmatchpresence) {
this.listeners.onmatchpresence(message.match_presence_event);
}
}
}
/**
* 发送消息并等待响应
*/
_send(message, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
if (!this.isConnected || !this.socketTask) {
reject(new Error('Socket not connected'));
return;
}
const cid = String(this.nextCid++);
message.cid = cid;
const timeout = setTimeout(() => {
delete this.pendingRequests[cid];
reject(new Error('Request timeout'));
}, timeoutMs);
this.pendingRequests[cid] = { resolve, reject, timeout };
this.socketTask.send({
data: JSON.stringify(message),
fail: (err) => {
delete this.pendingRequests[cid];
clearTimeout(timeout);
reject(new Error('Send failed: ' + err.errMsg));
}
});
});
}
/**
* 发送消息无需响应
*/
_sendNoResponse(message) {
if (!this.isConnected || !this.socketTask) {
console.error('[Nakama] Cannot send, not connected');
return;
}
this.socketTask.send({
data: JSON.stringify(message),
fail: (err) => {
console.error('[Nakama] Send failed:', err);
}
});
}
/**
* 开始匹配
*/
async findMatch(minCount, maxCount) {
if (!this.isConnected) {
console.log('[Nakama] Not connected, reconnecting...');
await this.authenticateWithGameToken(this.gameToken);
}
if (!this.gameToken) {
throw new Error('Missing game token in manager');
}
console.log('[Nakama] Adding to matchmaker:', minCount, '-', maxCount);
const response = await this._send({
matchmaker_add: {
min_count: minCount || 2,
max_count: maxCount || 2,
query: '+properties.game_token:*',
string_properties: { game_token: this.gameToken }
}
});
console.log('[Nakama] Matchmaker ticket:', response.matchmaker_ticket);
return response.matchmaker_ticket;
}
/**
* 加入比赛
*/
async joinMatch(matchId, token) {
console.log('[Nakama] Joining match:', matchId);
const join = { match_join: {} };
if (token) {
join.match_join.token = token;
} else {
join.match_join.match_id = matchId;
}
// 关键:传递 game_token 用于服务端验证
join.match_join.metadata = { game_token: this.gameToken };
const response = await this._send(join);
console.log('[Nakama] Joined match:', response.match?.match_id);
return response.match;
}
/**
* 调用 RPC
*/
async rpc(id, payload) {
console.log('[Nakama] RPC call:', id);
const response = await this._send({
rpc: {
id: id,
payload: typeof payload === 'string' ? payload : JSON.stringify(payload)
}
});
console.log('[Nakama] RPC response:', id, response);
if (response.rpc && response.rpc.payload) {
try {
const parsed = JSON.parse(response.rpc.payload);
console.log('[Nakama] RPC parsed result:', id, parsed);
return parsed;
} catch (e) {
return response.rpc.payload;
}
}
return response;
}
/**
* 发送游戏状态
*/
sendMatchState(matchId, opCode, data) {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
const op = parseInt(opCode);
console.log(`[Nakama] Sending state: Match=${matchId}, OpCode=${op}`);
this._sendNoResponse({
match_data_send: {
match_id: matchId,
op_code: op,
data: this._base64Encode(payload)
}
});
}
/**
* 断开连接
*/
disconnect() {
this._stopHeartbeat();
if (this.socketTask) {
this.socketTask.close();
this.socketTask = null;
}
this.isConnected = false;
this.session = null;
// 注意:不要清空 gameToken以便重连时仍然可以使用
// this.gameToken 只在 logout 或新 authenticate 时才会被更新
console.log('[Nakama] Disconnected');
}
// ============ 心跳 ============
_startHeartbeat() {
this._stopHeartbeat();
const currentHeartbeatId = ++this.heartbeatId;
console.log('[Nakama] Starting heartbeat version:', currentHeartbeatId);
this.heartbeatTimer = setInterval(() => {
// 如果此心跳 ID 不再是当前活跃 ID立即停止
if (this.heartbeatId !== currentHeartbeatId) {
console.log('[Nakama] Zombie heartbeat detected and stopped:', currentHeartbeatId);
clearInterval(this.heartbeatTimer);
return;
}
if (this.isConnected) {
this._send({ ping: {} }, 5000).catch((err) => {
console.warn('[Nakama] Heartbeat failed:', err.message);
// 如果发送失败且 socketTask 已断开,触发清理
if (!this.socketTask || (err.message && err.message.includes('not connected'))) {
this.disconnect();
}
});
}
}, this.heartbeatInterval);
}
_stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// ============ 工具方法 ============
_base64Encode(str) {
// 小程序环境没有 btoa需要手动实现
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
let output = '';
// 将字符串转为 UTF-8 字节数组
const bytes = [];
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
} else {
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
}
}
for (let i = 0; i < bytes.length; i += 3) {
const b1 = bytes[i];
const b2 = bytes[i + 1];
const b3 = bytes[i + 2];
output += chars.charAt(b1 >> 2);
output += chars.charAt(((b1 & 3) << 4) | (b2 >> 4) || 0);
output += b2 !== undefined ? chars.charAt(((b2 & 15) << 2) | (b3 >> 6) || 0) : '=';
output += b3 !== undefined ? chars.charAt(b3 & 63) : '=';
}
return output;
}
_base64ToUint8Array(base64) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const lookup = new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
let bufferLength = base64.length * 0.75;
if (base64[base64.length - 1] === '=') bufferLength--;
if (base64[base64.length - 2] === '=') bufferLength--;
const bytes = new Uint8Array(bufferLength);
let p = 0;
for (let i = 0; i < base64.length; i += 4) {
const e1 = lookup[base64.charCodeAt(i)];
const e2 = lookup[base64.charCodeAt(i + 1)];
const e3 = lookup[base64.charCodeAt(i + 2)];
const e4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (e1 << 2) | (e2 >> 4);
if (base64[i + 2] !== '=') bytes[p++] = ((e2 & 15) << 4) | (e3 >> 2);
if (base64[i + 3] !== '=') bytes[p++] = ((e3 & 3) << 6) | e4;
}
return bytes;
}
_utf8Decode(bytes) {
let out = "";
let i = 0;
while (i < bytes.length) {
let c = bytes[i++];
if (c >> 7 == 0) {
out += String.fromCharCode(c);
} else if (c >> 5 == 0x06) {
out += String.fromCharCode(((c & 0x1F) << 6) | (bytes[i++] & 0x3F));
} else if (c >> 4 == 0x0E) {
out += String.fromCharCode(((c & 0x0F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F));
} else {
out += String.fromCharCode(((c & 0x07) << 18) | ((bytes[i++] & 0x3F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F));
}
}
return out;
}
_parseUserIdFromToken(token) {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = parts[1];
// Base64 URL 解码
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '=='.slice(0, (4 - base64.length % 4) % 4);
// 解码 base64
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const lookup = {};
for (let i = 0; i < chars.length; i++) {
lookup[chars[i]] = i;
}
let bytes = [];
for (let i = 0; i < padded.length; i += 4) {
const e1 = lookup[padded[i]] || 0;
const e2 = lookup[padded[i + 1]] || 0;
const e3 = lookup[padded[i + 2]] || 0;
const e4 = lookup[padded[i + 3]] || 0;
bytes.push((e1 << 2) | (e2 >> 4));
if (padded[i + 2] !== '=') bytes.push(((e2 & 15) << 4) | (e3 >> 2));
if (padded[i + 3] !== '=') bytes.push(((e3 & 3) << 6) | e4);
}
// UTF-8 解码
let str = '';
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
const parsed = JSON.parse(str);
return parsed.uid || parsed.sub || null;
} catch (e) {
console.error('[Nakama] Failed to parse token:', e);
return null;
}
}
}
export const nakamaManager = new NakamaManager();

134
utils/payment.js Normal file
View File

@ -0,0 +1,134 @@
/**
* 通用支付流程工具函数
*
* 用于统一 一番赏无限赏对对碰 三种玩法的支付流程
*/
import { createWechatOrder } from '../api/appUser'
import { hasPhoneBound } from './checkPhone'
/**
* 从API响应中提取订单号
* @param {Object} res - API 响应
* @returns {string|null}
*/
export function extractOrderNo(res) {
if (!res) return null
return res.order_no || res.orderNo || res.data?.order_no || res.data?.orderNo || res.result?.order_no || res.result?.orderNo || null
}
/**
* 执行微信支付流程
*
* @param {Object} options
* @param {string} options.orderNo - 订单号必须
* @param {string} [options.openid] - 用户 openid可选默认从 storage 读取
* @returns {Promise<void>} - 支付完成成功 resolve取消或失败时 reject
*/
export async function doWechatPayment({ orderNo, openid }) {
if (!orderNo) {
throw new Error('订单号不能为空')
}
const finalOpenid = openid || uni.getStorageSync('openid')
if (!finalOpenid) {
throw new Error('缺少OpenID请重新登录')
}
// 1. 获取微信支付参数
const payRes = await createWechatOrder({ openid: finalOpenid, order_no: orderNo })
if (!payRes || !payRes.package) {
throw new Error('获取支付参数失败')
}
// 2. 调起微信支付
return new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: payRes.timeStamp || payRes.timestamp,
nonceStr: payRes.nonceStr || payRes.noncestr,
package: payRes.package,
signType: payRes.signType || 'RSA',
paySign: payRes.paySign,
success: resolve,
fail: (err) => {
if (err?.errMsg && String(err.errMsg).includes('cancel')) {
const cancelErr = new Error('支付已取消')
cancelErr.cancelled = true
reject(cancelErr)
} else {
reject(err)
}
}
})
})
}
/**
* 完整支付流程创建订单 + 支付
*
* @param {Object} options
* @param {Function} options.createOrder - 创建订单的函数返回 Promise结果需包含 order_no
* @param {string} [options.openid] - 用户 openid
* @param {Function} [options.onOrderCreated] - 订单创建后的回调参数为 (orderNo, response)
* @returns {Promise<{orderNo: string, orderResponse: any}>}
*/
export async function executePaymentFlow({ createOrder, openid, onOrderCreated }) {
// 1. 创建订单
const orderResponse = await createOrder()
const orderNo = extractOrderNo(orderResponse)
if (!orderNo) {
throw new Error('未获取到订单号')
}
// 2. 回调通知(用于保存游戏数据等)
if (typeof onOrderCreated === 'function') {
await onOrderCreated(orderNo, orderResponse)
}
// 3. 执行支付
await doWechatPayment({ orderNo, openid })
return { orderNo, orderResponse }
}
/**
* 检查登录状态
* @returns {{ok: boolean, openid?: string, message?: string}}
*/
export function checkLoginStatus() {
const token = uni.getStorageSync('token')
const openid = uni.getStorageSync('openid')
if (!token) {
return { ok: false, message: '请先登录' }
}
// 使用统一的手机号绑定检查
if (!hasPhoneBound()) {
return { ok: false, message: '请先绑定手机号' }
}
if (!openid) {
return { ok: false, message: '缺少OpenID请重新登录' }
}
return { ok: true, openid }
}
/**
* 显示登录提示弹窗
*/
export function showLoginPrompt() {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
}

View File

@ -1,4 +1,4 @@
const BASE_URL = 'https://mini-chat.1024tool.vip'
const BASE_URL = 'https://kdy.1024tool.vip'
let authModalShown = false
@ -22,7 +22,6 @@ function handleAuthExpired() {
export function request({ url, method = 'GET', data = {}, header = {} }) {
return new Promise((resolve, reject) => {
const finalHeader = { ...buildDefaultHeaders(), ...header }
try { console.log('HTTP request', method, url, 'data', data, 'headers', finalHeader) } catch (e) {}
uni.request({
url: BASE_URL + url,
method,
@ -31,7 +30,6 @@ export function request({ url, method = 'GET', data = {}, header = {} }) {
timeout: 15000,
success: (res) => {
const code = res.statusCode
try { console.log('HTTP response', method, url, 'status', code, 'body', res.data) } catch (e) {}
if (code >= 200 && code < 300) {
const body = res.data
resolve(body && body.data !== undefined ? body.data : body)
@ -42,12 +40,22 @@ export function request({ url, method = 'GET', data = {}, header = {} }) {
handleAuthExpired()
}
}
// 检查是否是商品缺货错误 (code: 20002)
// 仅当是商品详情接口时才弹窗
if (res.data && res.data.code === 20002 && url.startsWith('/api/app/products/')) {
uni.showModal({
title: '商品库存不足',
content: '当前商品库存不足,由于市场价格存在波动请联系客服核对价格,补充库存。',
showCancel: false
})
}
const msg = (res.data && (res.data.message || res.data.msg)) || '请求错误'
reject({ message: msg, statusCode: code, data: res.data })
}
},
fail: (err) => {
try { console.error('HTTP fail', method, url, 'err', err) } catch (e) {}
reject(err)
}
})
@ -58,14 +66,21 @@ export function authRequest(options) {
const token = uni.getStorageSync('token')
const base = buildDefaultHeaders()
const header = { ...base, ...(options.header || {}) }
// 不再添加 Bearer直接原样透传 token
// 设置Authorization头
if (token) {
header.Authorization = token
header.authorization = token
}
return request({ ...options, header })
}
export function redeemProductByPoints(user_id, product_id, quantity) {
return authRequest({
url: `/api/app/users/${user_id}/points/redeem-product`,
method: 'POST',
data: { product_id, quantity }
})
}
function getLanguage() {
try { return (uni.getSystemInfoSync().language || 'zh-CN') } catch (_) { return 'zh-CN' }
}

View File

@ -3,8 +3,18 @@
* 用于在抽奖前请求用户授权接收开奖通知
*/
// 抽奖结果通知模板 ID
const LOTTERY_RESULT_TEMPLATE_ID = 'O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI'
// 抽奖结果通知模板 ID (默认兜底)
const DEFAULT_LOTTERY_RESULT_TEMPLATE_ID = 'O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI'
function getLotteryTemplateId() {
try {
const templates = uni.getStorageSync('subscribe_templates')
if (templates && templates.lottery_result) {
return templates.lottery_result
}
} catch (e) { console.error(e) }
return DEFAULT_LOTTERY_RESULT_TEMPLATE_ID
}
/**
* 请求用户订阅抽奖结果通知
@ -13,15 +23,16 @@ const LOTTERY_RESULT_TEMPLATE_ID = 'O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI'
export function requestLotterySubscription() {
return new Promise((resolve) => {
// #ifdef MP-WEIXIN
const tmplId = getLotteryTemplateId()
wx.requestSubscribeMessage({
tmplIds: [LOTTERY_RESULT_TEMPLATE_ID],
tmplIds: [tmplId],
success(res) {
console.log('订阅消息授权结果:', res)
resolve({
success: true,
result: res,
// 检查用户是否接受了订阅
accepted: res[LOTTERY_RESULT_TEMPLATE_ID] === 'accept'
accepted: res[tmplId] === 'accept'
})
},
fail(err) {
@ -86,5 +97,5 @@ export function requestSubscriptions(templateIds) {
export default {
requestLotterySubscription,
requestSubscriptions,
LOTTERY_RESULT_TEMPLATE_ID
LOTTERY_RESULT_TEMPLATE_ID: DEFAULT_LOTTERY_RESULT_TEMPLATE_ID
}

52
utils/vibrate.js Normal file
View File

@ -0,0 +1,52 @@
/**
* 震动工具函数
* 统一处理不同平台的震动API兼容性
*/
/**
* 短震动
* 微信小程序不支持 type 参数会忽略该参数
* @param {Object} options - 配置项
* @param {string} options.type - 震动类型 'light' | 'medium' | 'heavy'仅在部分平台有效
*/
export function vibrateShort(options = {}) {
// #ifdef MP-WEIXIN
// 微信小程序不支持 type 参数,直接调用
uni.vibrateShort({
fail: (err) => {
console.warn('[vibrateShort] 震动失败:', err)
}
})
// #endif
// #ifdef H5 || APP-PLUS
// H5和App可能支持 type 参数
uni.vibrateShort({
...options,
fail: (err) => {
console.warn('[vibrateShort] 震动失败:', err)
}
})
// #endif
// #ifdef MP-ALIPAY || MP-BAIDU || MP-TOUTIAO
// 其他小程序平台,尝试传递参数
uni.vibrateShort({
...options,
fail: (err) => {
console.warn('[vibrateShort] 震动失败:', err)
}
})
// #endif
}
/**
* 长震动
*/
export function vibrateLong() {
uni.vibrateLong({
fail: (err) => {
console.warn('[vibrateLong] 震动失败:', err)
}
})
}

View File

@ -31,5 +31,10 @@
* [x] 2025-12-17: 修复 `pages/login/index.vue` 等多处 `$border-color` 未定义错误,在 `uni.scss` 中增加变量别名。
* [x] 2025-12-17: 修复 `pages/mine/index.vue` 编译错误,在 `api/appUser.js` 中补充 `getUserInfo`, `getUserTasks`, `getInviteRecords` 导出。
* [x] 2025-12-17: 将 dev 分支代码强制推送至 main 分支 (Deployment/Sync)。
* [x] 2025-12-18: 实现订单详情 API 与取消订单 API (后端接口对接)。
* [x] 2025-12-18: 开发订单详情页 UI 及交互逻辑。
* [x] 2025-12-22: 修复订单列表不显示问题,移除 source_type=3 过滤,并支持对对碰等玩法订单的正确展示(列表与详情)。
* [x] 2025-12-22: 修复订单列表标题显示为 "matching_game:xxx" 内部标识的问题,优化无商品信息时的标题展示。
* [x] 2025-12-22: 优化订单详情页,当没有实物商品时(如参与记录)显示活动信息,避免显示空的商品清单。
* [ ] 2025-12-17: 进行中 - 优化 `pages/activity/yifanshang/index.vue` 及相关组件。
* [ ] 2025-12-17: 待开始 - 优化 `pages/login/index.vue` 视觉细节。