Compare commits

...

190 Commits
dev ... main

Author SHA1 Message Date
d530ec11e7 feat(activity): 新增小程序奖品领取弹窗
新增首页承载的奖品领取弹窗与领取接口接入,支持待领取检查、会话静默关闭与领取操作展示。
2026-05-07 22:10:15 +08:00
8a3676eb9f fix(activity): 优化福利活动未开始提示
福利活动详情页在开始前显示未开始状态与开始时间提示,避免用户误以为当前可参与。
2026-05-02 23:04:43 +08:00
fb520a6895 fix(agreement): 补充购买协议发货与售后说明
补充收货开箱视频留存要求和质量问题处理说明,明确售后依据与平台协助范围。
2026-04-30 18:40:07 +08:00
2895c2d5b7 fix(activity): 完善福利活动前台展示与参与体验
补齐福利活动前台页面与请求接入,优化奖品展示、参与进度、中奖概览与无图占位文案。
同时修复奖品文字重叠问题,提升福利活动详情页的可读性。
2026-04-29 17:22:05 +08:00
win
e0a1d6e934 x 2026-04-21 12:53:34 +08:00
575ccb2cfa feat: 盒柜接入运费校验并支持一键合成
本次提交同步补齐小程序端对后端新能力的接入,既支持碎片一键合成,也支持盒柜发货前按商品分类动态判断是否必须支付运费。

- 合成页:新增一键合成入口,展示最大可合成次数,并将单次合成与批量合成交互拆分为更清晰的双按钮布局
- 盒柜页:碎片合成区同步支持批量合成,合成成功后同时刷新配方列表与背包数据
- 运费流程:发货前先调用后端运费检查接口,根据“件数不足”或“包含不包邮商品”展示不同确认文案,再决定是否创建运费订单
- API 封装:补充批量合成与运费检查接口,确保前端逻辑与后端规则保持一致
2026-04-21 02:08:24 +08:00
win
eca0561cd9 排行榜扫雷 2026-04-20 15:53:49 +08:00
21a174329c feat(cabinet): 发货卡片展示收货地址并切到本地联调
在发货列表卡片中增加收货地址展示,接入 shipments 接口返回的 address 字段,并在前端做地址字段归一化处理,兼容不同命名格式。
同时将请求基址切换为本地 127.0.0.1 便于联调,方便直接验证发货申请后卡片是否正确展示本次选择的收货地址。
2026-04-17 20:42:47 +08:00
49027862b3 Merge remote-tracking branch 'origin/main' into zuncle 2026-04-10 14:59:03 +08:00
d0e0b5d4ea fix(auth): 调整手机号快捷登录页图标与文案 2026-04-10 14:58:29 +08:00
win
5c88d91382 merge: resolve conflict from origin/zuncle - keep async onShow with phone bind check 2026-04-01 01:30:52 +08:00
63345f4c24 feat: 发货流程增加地址选择弹窗 2026-04-01 00:55:23 +08:00
d7cd33bcca fix(shipping): 反转运费规则为不满5件收运费,满5件包邮
将 onShip() 中运费判断从 > 5 件收运费改为 < 5 件收运费
2026-03-31 21:13:57 +08:00
win
0c794101e7 Merge remote-tracking branch 'origin/zuncle' 2026-03-26 15:39:52 +08:00
27a05210ee fix(auth): 修复活动页和商品详情页未登录即弹登录框导致审核失败
问题背景:
- 平台审核结论:页面未完整浏览、体验详情时即要求授权登录,属于不合规
- 用户应能先浏览页面内容,仅在执行操作(抽奖/兑换/购买)时才引导登录

根因分析:
1. api/appUser.js 中活动浏览类 API(getActivityDetail 等)使用 authRequest,
   虽然后端接口是公开的,但同页面的 getGamePasses 等需认证接口返回 401
   触发全局登录弹窗
2. getProductDetail 使用 authRequest 调用认证接口,未登录直接 401
3. 全局 401 拦截器不区分浏览请求和操作请求

修改内容:
1. api/appUser.js: 6 个浏览类 API 函数从 authRequest 改为 request
   - getActivityDetail, getActivityIssues, getActivityIssueRewards
   - getIssueDrawLogs, getMatchingCardTypes, getProductDetail
   这些接口在后端均为公开路由,不需要携带 token

2. 活动页面 onLoad 中条件调用认证接口:
   - wuxianshang/index.vue: fetchPasses() 仅在已登录时调用
   - yifanshang/index.vue: fetchPasses() 仅在已登录时调用
   - duiduipeng/index.vue: fetchGamePasses() 仅在已登录时调用
   次数卡(game passes)接口需要认证,未登录时跳过即可,
   不影响页面浏览体验

3. utils/request.js: request() 函数增加 suppressAuthModal 参数
   支持调用方按需静默 401 弹窗,作为安全兜底机制

验证场景:
- 未登录 → 打开无限赏/一番赏/对对碰/商品详情 → 正常显示,无登录弹窗
- 未登录 → 点击抽奖/兑换按钮 → 弹出登录提示(符合平台规范)
- 已登录 → 所有功能正常,次数卡信息正常加载
2026-03-26 14:35:26 +08:00
win
e29864eb4e Merge remote-tracking branch 'origin/zuncle' 2026-03-25 23:33:44 +08:00
7487e7224a feat(无限赏): 恢复奖池查看全部弹窗,新增参考价和概率总览
- 恢复无限赏页面"查看全部"按钮和 RewardsPopup 弹窗
- RewardsPopup 顶部新增按档次分类的中奖率概览条
- 奖品项显示参考价(来自后端 price_snapshot_cents)
- 每个奖品图片左下角添加档次标签(S赏/A赏/BOSS赏等)
- normalizeRewards 新增 product_price 字段提取
- 理性消费提示改为始终显示
2026-03-25 22:01:22 +08:00
win
7acbc515aa merge: resolve conflicts from origin/zuncle - shop no token required, cabinet keep both imports 2026-03-24 18:30:02 +08:00
495b46ec8b fix: 允许未登录用户浏览首页和商城,解决微信审核拒绝问题
移除首页和商城页的强制登录拦截,商城API改用公开请求,
用户可先浏览再自行选择登录。
2026-03-24 17:11:03 +08:00
tsui110
58ad9e8be3 修改问题,新增登陆和修改合规整改 2026-03-24 12:10:53 +08:00
d55be3dbcf feat(fragment): 小程序适配碎片多数量产出展示
- normalizeRewards 传递 drop_quantity 字段
- 抽奖结果弹窗自动合并同类碎片显示 x{N}
- 购买记录仅 count>1 时显示数量角标(当前不展示)
- RewardsPreview/RewardsPopup 保留 drop_quantity 样式(当前不展示)
2026-03-23 22:27:50 +08:00
tsui110
d643abe7e1 修改若干安卓端的语法 2026-03-23 20:28:33 +08:00
eb3257f1bd feat(无限赏): 隐藏奖池"查看全部"按钮,增加理性消费提示
1. RewardsPreview 组件新增 hideViewAll prop(默认 false):
   - hideViewAll=true 时隐藏"查看全部"按钮,显示理性消费提示
   - hideViewAll=false 时保持原有行为,不影响其他页面
   - 提示文字样式:橙色文字 + 浅橙背景 + 左侧竖条装饰

2. 无限赏页面(wuxianshang/index.vue):
   - RewardsPreview 传入 :hide-view-all="true"
   - 移除 @view-all 事件绑定
   - 注释掉 RewardsPopup 奖池详情弹窗(保留代码便于后续恢复)

3. 对对碰页面(duiduipeng/index.vue):
   - 不受影响,保持原有"查看全部"按钮和弹窗功能

提示内容:每抽都有概率出以下商品,盲盒消费具有随机性,请理性消费
2026-03-22 16:57:22 +08:00
win
fd252efae1 优化UI 2026-03-20 00:57:42 +08:00
bdd329eb15 feat(mini): add fragment synthesis page and cabinet fragment UX 2026-03-19 16:27:54 +08:00
3e20dd845a fix: 前端过滤 sub_status=expired 的优惠券
yifanshang/wuxianshang/duiduipeng 三个活动页面的 fetchCoupons
在赋值前过滤掉已过期的券,作为后端的防御层
2026-03-18 21:58:41 +08:00
bcbe7a9b29 feat(shipping): 前端添加48小时撤销限制
- 添加checkCanCancel函数判断是否在48小时内
- 超过48小时不显示撤销发货按钮
- 点击撤销时如超过48小时提示'需要撤销发货请联系客服'
2026-03-17 18:11:05 +08:00
be915a1507 fix: 赠送填写地址页强制登录,防止地址归属错误
- 未登录时弹窗引导登录后再填写
- onShow检测登录状态变化,登录后自动加载地址列表
- onSubmit增加登录检查防线
2026-03-15 13:18:38 +08:00
499ac1514e feat: H5扫雷游戏WebView对接优化
- 动态拼接游戏URL(client_url/nakama_server)
- 传递nickname参数给H5
- 添加游戏URL调试日志
2026-03-14 22:49:38 +08:00
win
16076f2eb8 次卡+道具卡 道具卡不生效 2026-02-27 20:57:24 +08:00
4fe3ecb571 任务大厅,限量显示 2026-02-19 19:55:00 +08:00
b97cd0f267 优惠券显示 2026-02-18 22:34:13 +08:00
29f272c22c 优惠券 2026-02-16 16:10:10 +08:00
dc2297bbdf fix:修复扫雷 免费-付费匹配问题 2026-02-08 11:04:34 +08:00
tsui110
4d9f7e84e3 修改历史记录不再显示时间 2026-02-07 18:22:23 +08:00
tsui110
7fb865b68e 修复了轮播图显示不完整的问题 2026-02-07 01:02:07 +08:00
tsui110
636041d6fa 修复了大部分样式引起的小问题 2026-02-07 00:58:10 +08:00
e7256ae88e x 2026-02-06 18:38:09 +08:00
tsui110
2a98cde85f 修改UI风格为粘土拟态风格 2026-02-05 16:00:40 +08:00
tsui110
90110f5bce 为符合微信小程序要求,修改微信登录为手机号快捷登录 2026-02-04 21:49:30 +08:00
tsui110
ec0a96087c 修改base_url 2026-02-04 21:40:12 +08:00
57178b21b3 移除填写邀请码功能 2026-02-04 13:27:10 +08:00
662a31dac8 Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini
# 请输入一个提交信息以解释此合并的必要性,尤其是将一个更新后的上游分支
# 合并到主题分支。
#
# 以 '#' 开始的行将被忽略,而空的提交说明将终止提交。
2026-02-04 13:09:45 +08:00
cdfe233ea8 绑定邀请码 2026-02-04 13:09:01 +08:00
tsui110
f918bfc81a 修复优惠券显示错误的问题 2026-02-03 19:20:31 +08:00
1cfa7e8322 次卡更新 2026-02-03 17:43:34 +08:00
35932622e0 扫雷免费 2026-01-29 19:12:04 +08:00
tsui110
ba89b0f2dc 修改base_url,修复错误的wss默认地址 2026-01-27 19:42:57 +08:00
f83048f3e9 优惠券 2026-01-27 11:30:18 +08:00
c55fc2954f 优惠券 2026-01-27 01:33:59 +08:00
tsui110
6451394764 修改一个微信版本的流程问题 2026-01-21 17:00:54 +08:00
tsui110
ef2ebe754f 修复https错误 2026-01-21 14:36:58 +08:00
tsui110
33523d2306 修改了历史记录的提示文本 2026-01-21 12:39:52 +08:00
tsui110
2390db8186 修改链接切换为正式服务器地址 2026-01-20 21:11:06 +08:00
b6ec1958a2 联系客服 2026-01-20 14:22:45 +08:00
51c6e872f3 扫雷人数 2026-01-19 16:54:00 +08:00
tsui110
1af8bc7315 修改了绑定抖音的文字提示 2026-01-17 23:40:49 +08:00
tsui110
108f37e35f 屏蔽了调试模式下的日志错误,修改了默认服务地址错误的问题,更为正确的wss服务器地址。 2026-01-16 12:25:02 +08:00
5c863de337 chore: 完成合并并解决冲突 2026-01-15 16:45:05 +08:00
83001cfda9 添加凭证复制按钮 2026-01-15 16:44:08 +08:00
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
118 changed files with 38340 additions and 5623 deletions

4
.gitignore vendored Normal file → Executable file
View File

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

View File

@ -1,46 +0,0 @@
## 接口梳理App 用户相关)
- 登录与绑定:
- `POST /api/app/users/weixin/login`,请求含 `code``invite_code`;响应含 `token``user_id``avatar``nickname``invite_code`.trae/documents/基于 Swagger 的 App 用户 API 汇总与 Uni-App 微信登录页面实现方案.md:5-10
- `POST /api/app/users/{user_id}/phone/bind`,请求含微信手机号 `code`;响应为标准成功(.trae/documents/基于 Swagger 的 App 用户 API 汇总与 Uni-App 微信登录页面实现方案.md:11-16
- 用户资料与地址:
- `PUT /api/app/users/{user_id}`(修改头像/昵称,`avatar``nickname`:19-22
- 地址列表/新增/删除/设默认(`:23-27`),新增请求含基本地址字段(`:25-27`
- 积分与统计:
- `GET /api/app/users/{user_id}/points/balance` 响应 `balance``:31-34`
- `GET /api/app/users/{user_id}/stats` 响应 `coupon_count``item_card_count``points_balance``:35-37`
- 订单与卡券/道具:
- 订单列表、优惠券、邀请、道具卡与使用记录(`:40-48`
## 技术方案
- 网络层:
- 方案A推荐复用 `alova` 客户端与生成器,统一 `Authorization` 与错误处理(`:51`
- 方案B 以 `uni.request` 封装最小所需接口(登录/绑定/统计),在请求头注入 `Bearer` token`:71-72`
- 平台与配置:
- `baseURL` 指向后端 `http://127.0.0.1:9991`;在微信小程序后台配置合法域名与 HTTPS 证书(`:75`
- 状态与路由:
- `pages.json` 添加 `pages/login/index`;登录成功后 `uni.reLaunch` 到首页;用 `pinia` 管理 `isLogin``userInfo``points``:79`
- 错误处理:
- 按既有分类提示与重试策略覆盖网络错误、超时、404/500、参数错误`:83`
## 页面实现Uni-App Vue3
- 结构Logo/说明、`「微信登录」`按钮、`open-type="getPhoneNumber"` 授权按钮、加载与错误提示(`:57-58`
- 流程:
- 触发 `uni.login({ provider: 'weixin' })` 获取 `code` → 调用 `POST /api/app/users/weixin/login` → 持久化 `token``user_id``:61-62`
- 可选手机号绑定:`onGetPhoneNumber``code``POST /api/app/users/{user_id}/phone/bind``:63-64`
- 登录后拉取统计与积分余额更新首页(`:65-66`
## 交付内容
- 新增 `pages/login/index.vue`Composition API含完整登录/绑定流程与错误提示(`:91-94`
- 接入并配置网络层(复用 `alova` 或最小 `uni.request` 封装)
- 路由与 `pinia` 状态的最小接入
## 验证方法
- 在开发者工具/真机验证:`code` 获取、接口返回、`token/user_id` 存储、后续接口成功(`:87-88`
- 输出必要的调试日志(不含敏感信息),观察错误分支与重试入口
## 执行步骤
1. 接入 Swagger JSON`http://127.0.0.1:9991/swagger/v1/swagger.json`)同步生成或确认接口(`:99-101`
2. 选定网络层方案并落地调用
3. 新增登录页面与交互逻辑
4. 调整路由与状态管理
5. 自测与联调,完成交付

View File

@ -1,102 +0,0 @@
## API 文档汇总App 用户相关)
* 登录与绑定:
* `POST /api/app/users/weixin/login`miniapp/src/api/apis/apiDefinitions.js:106
* 请求: `App_weixin_login_request`code、invite\_code可选
* 响应: `App_weixin_login_response`token、user\_id、avatar、nickname、invite\_codeminiapp/src/api/apis/globals.d.ts:760
* `POST /api/app/users/{user_id}/phone/bind`apiDefinitions.js:123
* 请求: `App_bind_phone_request`code来源于微信手机号授权globals.d.ts:376
* 响应: 成功布尔或标准成功结构(项目统一在 `responded` 钩子返回 `response.data``response.data.data`miniapp/src/api/apis/index.js:59
* 用户资料与地址:
* `PUT /api/app/users/{user_id}` 修改头像/昵称apiDefinitions.js:107
* 请求: `App_modify_user_request`avatar、nickname可选globals.d.ts:363
* `GET /api/app/users/{user_id}/addresses` 列表apiDefinitions.js:108/ `POST` 新增apiDefinitions.js:109/ `DELETE` 删除apiDefinitions.js:110/ `PUT .../default` 设默认apiDefinitions.js:114
* 新增请求: `App_add_address_request`姓名、手机号、省市区、详细地址、是否默认globals.d.ts:367
* 响应: 列表返回数组,新增/删除/设默认返回标准成功结构(项目统一 `responded` 处理)
* 积分与统计:
* `GET /api/app/users/{user_id}/points`apiDefinitions.js:124/ `GET .../points/balance`apiDefinitions.js:125
* 响应: `App_points_balance_response`balanceglobals.d.ts:773
* `GET /api/app/users/{user_id}/stats`apiDefinitions.js:126
* 响应: `App_user_stats_response`coupon\_count、item\_card\_count、points\_balanceglobals.d.ts:776
* 订单与卡券/道具:
* `GET /api/app/users/{user_id}/orders`apiDefinitions.js:122→ 订单列表(类型包含 `Model_order_items` 等)
* `GET /api/app/users/{user_id}/coupons`apiDefinitions.js:118/ `GET .../invites`apiDefinitions.js:119
* `GET /api/app/users/{user_id}/item_cards`apiDefinitions.js:120/ `GET .../item_cards/uses`apiDefinitions.js:121
* 响应: `User_item_card_with_template[]`globals.d.ts:978
## 现有代码要点(可复用)
* API 客户端:`alova` + 生成器miniapp/src/api/apis/index.js:35、112miniapp/alova.config.js:6已封装 `Authorization`、401 刷新登录index.js:9-33
* 登录页Taro版`miniapp/src/pages/login/index.vue`,逻辑封装在 `Apis.login.WechatAppLogin`index.js:122-421
## 登录页面实现Uni-App Vue3
* 页面结构Logo/说明文案、按钮`「微信登录」``open-type="getPhoneNumber"`的手机号授权按钮,加载与错误提示。
* 流程:
* `uni.login({ provider: 'weixin' })` 获取 `code` → 调用 `POST /api/app/users/weixin/login` → 存储 `token``user_id``uni.setStorageSync`
* 可选:用户点击手机号授权后触发 `onGetPhoneNumber`,拿到 `code` 调用 `POST /api/app/users/{user_id}/phone/bind` 绑定手机号。
* 登录完成后拉取 `GET /api/app/users/{user_id}/stats``.../points/balance` 更新首页状态。
* 网络层:
* 方案A推荐复用现有在 Uni-App 中引入与复用 `alova` 生成的 `Apis`(保持统一的拦截器与基址、响应处理)。
* 方案B轻量使用 `uni.request` 封装最小调用(登录/绑定/统计),按现有 `Authorization: Bearer <token>` 规则注入。
* 配置与安全:
* `baseURL` 指向后端地址(如 `http://127.0.0.1:9991`),并在微信小程序后台配置合法域名/证书;避免在日志中输出明文 token/手机号等敏感信息。
* 路由与状态:
* 在 `pages.json` 新增 `pages/login/index`,登录成功后 `uni.reLaunch` 到首页;使用 `pinia` 存储 `isLogin``userInfo``points` 等(参考 miniapp/src/store/index.js
* 错误处理:
* 按当前项目的分类提示连接被拒绝、超时、域名未配置、SSL 错误、404、500、参数错误进行用户级文案与重试入口参考 index.js:221-253
## 验证与交付
* 验证:真机或开发者工具下,观察 `code` 获取、接口返回、`token/user_id` 存储与后续接口成功;埋点或日志控制台输出关键步骤。
* 交付:
* 新增 `pages/login/index.vue`Uni-App Vue3 Composition API 实现)。
* 复用或新增 API 封装A/B 二选一)。
* 配置/路由调整与最小 `pinia` 状态接入。
## 后续执行步骤
* 接入 Swagger 源:将生成器输入指向 `http://127.0.0.1:9991/swagger/v1/swagger.json`(或项目后端的 Swagger JSON生成/更新 `Apis` 并对齐 `baseURL`
* 按上述方案完成页面与调用接入,并保持与现有 `alova` 响应处理一致性。

View File

@ -1,35 +0,0 @@
## 目标
- 首页 UI 始终可见轮播图在无数据时也显示占位滑块通知始终滚动显示Marquee
- 接口路径统一为 `/api/app/*`,兼容返回 `{list: [...]}` 与字段 `snake_case`(如 `image_url`)。
## 变更范围
- 文件:`pages/index/index.vue`
- 保留现有登录弹窗,但不阻断首页数据加载;完善数据清洗与空态展示。
## 轮播图(无数据也展示)
- 始终渲染 `swiper.banner-swiper`,不再用 `v-if` 隐藏容器。
- 数据存在:按 `banners` 渲染;字段映射 `id``image_url|imageUrl|image``link_url|linkUrl|link|url`
- 数据为空:渲染 3 个占位滑块(`swiper-item` 内用 `<view>` 纯色/渐变背景 + 文案“敬请期待”),避免依赖静态图片资源。
- URL 清洗:移除反引号与空格,保证 `image_url` 可用。
## 通知滚动Marquee
- 始终渲染通知条。
- 有数据横向无缝滚动CSS `@keyframes` + `transform: translateX`),将所有通知拼接为一条长文本,重复一份以实现循环滚动。
- 无数据:使用默认文案(如“欢迎光临”“最新活动敬请期待”)参与滚动,保证始终有动效。
## 活动区
- 保持当前两列栅格布局;无数据时显示“暂无活动”占位文案;点击仅在 `link` 为内部路径时跳转。
## 数据与接口
- 请求入口统一:`/api/app/notices``/api/app/banners``/api/app/activities`
- 解包:支持 `list|items|data`
- 映射:通知 `content|text|title`;轮播图 `image_url|imageUrl|image|img|pic``link_url|linkUrl|link|url`;活动 `cover_url|coverUrl|image|img|pic``title|name``sub_title|subTitle|subtitle|desc|description`
## 交互与空态
- 未登录/未绑定:弹窗提醒,但首页照常加载并显示占位内容。
- 点击跳转:内部路径以 `/` 开头才触发 `navigateTo`;避免空链接导致错误。
## 验证
- 模拟后端返回 `{list:[...]}` 含反引号的 `image_url`,确认轮播图正常显示。
- 清空 `banners``notices`,确认占位滑块与默认滚动文案显示。
- 在微信小程序/浏览器预览,验证滚动流畅度与样式适配。

40
App.vue Normal file → Executable file
View File

@ -1,20 +1,32 @@
<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) }
}
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">

3
androidPrivacy.json Normal file
View File

@ -0,0 +1,3 @@
{
"prompt" : "template"
}

302
api/appUser.js Normal file → Executable file
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' })
}
@ -63,43 +117,65 @@ export function setDefaultAddress(user_id, address_id) {
}
export function getActivityDetail(activity_id) {
return authRequest({ url: `/api/app/activities/${activity_id}`, method: 'GET' })
return request({ url: `/api/app/activities/${activity_id}`, method: 'GET' })
}
export function getActivityIssues(activity_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues`, method: 'GET' })
return request({ url: `/api/app/activities/${activity_id}/issues`, method: 'GET' })
}
export function getActivityIssueRewards(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/rewards`, method: 'GET' })
return request({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/rewards`, method: 'GET' })
}
export function getIssueDrawLogs(activity_id, issue_id) {
return request({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw_logs`, 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 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 getIssueChoices(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/choices`, method: 'GET' })
}
export function getProductDetail(product_id) {
return authRequest({ url: `/api/app/products/${product_id}`, method: 'GET' })
return request({ url: `/api/app/products/${product_id}`, method: 'GET' })
}
export function redeemInventory(user_id, ids) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/redeem`, method: 'POST', data: { inventory_ids: ids } })
}
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 requestShipping(user_id, ids, address_id) {
const data = { inventory_ids: ids }
if (address_id) data.address_id = address_id
return authRequest({ url: `/api/app/users/${user_id}/inventory/request-shipping`, method: 'POST', data })
}
export function getItemCards(user_id, status) {
const data = {}
export function checkShippingFee(user_id, ids) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/shipping-fee/check`, method: 'POST', data: { inventory_ids: ids } })
}
export function createShippingFeeOrder(user_id, ids) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/shipping-fee/preorder`, method: 'POST', data: { inventory_ids: ids } })
}
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,10 +206,32 @@ 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 })
}
export function createWechatAppOrder(data) {
return authRequest({ url: '/api/app/pay/wechat/app/preorder', method: 'POST', data })
}
export function getLotteryResult(order_no) {
return authRequest({ url: '/api/app/lottery/result', method: 'GET', data: { order_no } })
}
@ -146,10 +244,43 @@ 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)
}
// 添加分类ID筛选
if (filters.category_id !== undefined && filters.category_id !== null && filters.category_id > 0) {
data.category_id = filters.category_id
}
return request({ url: '/api/app/store/items', method: 'GET', data })
}
export function getProductCategories() {
return request({ url: '/api/app/product_categories', method: 'GET' })
}
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 } })
}
@ -160,17 +291,158 @@ 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 request({ 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 - 购买数量
*/
/**
* 购买次数卡套餐
* @param {number} package_id - 套餐ID
* @param {number} count - 购买数量
* @param {Array<number>} coupon_ids - 优惠券ID列表
*/
export function purchaseGamePass(package_id, count = 1, coupon_ids = []) {
const data = { package_id, count }
if (coupon_ids && coupon_ids.length > 0) {
data.coupon_ids = coupon_ids
}
return authRequest({ url: '/api/app/game-passes/purchase', method: 'POST', data })
}
/**
* 绑定抖音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 } })
}
/**
* 同步当前用户绑定的抖音订单
*/
export function syncMyDouyinOrders() {
return authRequest({ url: '/api/app/users/douyin/orders/sync', method: 'POST' })
}

9
api/prizeClaim.js Normal file
View File

@ -0,0 +1,9 @@
import { authRequest } from '@/utils/request'
export function getPendingPrizeGrantActivity() {
return authRequest({ url: '/api/app/prize-grant-activities/pending', method: 'GET' })
}
export function claimPrizeGrantActivity(id) {
return authRequest({ url: `/api/app/prize-grant-activities/${id}/claim`, method: 'POST' })
}

17
api/synthesis.js Normal file
View File

@ -0,0 +1,17 @@
import { authRequest } from '../utils/request'
export function getSynthesisRecipes(userId) {
return authRequest({ url: `/api/app/users/${userId}/synthesis/recipes`, method: 'GET' })
}
export function doSynthesis(userId, recipeId) {
return authRequest({ url: `/api/app/users/${userId}/synthesis/do`, method: 'POST', data: { recipe_id: recipeId } })
}
export function doBatchSynthesis(userId, recipeId) {
return authRequest({ url: `/api/app/users/${userId}/synthesis/do-batch`, method: 'POST', data: { recipe_id: recipeId } })
}
export function getSynthesisLogs(userId, page = 1, pageSize = 20) {
return authRequest({ url: `/api/app/users/${userId}/synthesis/logs`, method: 'GET', data: { page, page_size: pageSize } })
}

338
components/BoxReveal.vue Executable 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>

271
components/ClayButton.vue Executable file
View File

@ -0,0 +1,271 @@
<template>
<view
class="clay-button-wrapper"
:class="[
`clay-btn-${size}`,
{ 'clay-btn-block': block },
{ 'clay-btn-disabled': disabled }
]"
>
<view
class="clay-button"
:class="[
`clay-btn-${variant}`,
{ 'clay-btn-outline': outline },
{ 'is-loading': loading }
]"
:style="customStyle"
@tap="handleTap"
>
<!-- 加载动画 -->
<view v-if="loading" class="clay-loading">
<view class="loading-spinner"></view>
</view>
<!-- 图标 -->
<view v-if="icon && !loading" class="clay-icon">
<text>{{ icon }}</text>
</view>
<!-- 按钮文字 -->
<text class="clay-text">{{ text }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'ClayButton',
props: {
//
text: {
type: String,
default: '按钮'
},
// sm, md, lg
size: {
type: String,
default: 'md'
},
// primary, secondary, success, warning, error
variant: {
type: String,
default: 'primary'
},
//
outline: {
type: Boolean,
default: false
},
//
block: {
type: Boolean,
default: false
},
//
disabled: {
type: Boolean,
default: false
},
//
loading: {
type: Boolean,
default: false
},
// emoji
icon: {
type: String,
default: ''
},
//
customStyle: {
type: Object,
default: () => ({})
}
},
methods: {
handleTap(e) {
if (this.disabled || this.loading) return
this.$emit('tap', e)
}
}
}
</script>
<style lang="scss" scoped>
/* ============================================
Claymorphism 按钮组件
使用示例
<ClayButton text="确认" variant="primary" size="lg" @tap="handleConfirm" />
============================================ */
.clay-button-wrapper {
display: inline-flex;
&.clay-btn-block {
display: flex;
width: 100%;
}
&.clay-btn-disabled {
opacity: 0.5;
pointer-events: none;
}
}
.clay-button {
border-radius: 50rpx;
font-weight: 700;
position: relative;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
/* Claymorphism 双阴影 - 创造凸起感 */
box-shadow:
8rpx 8rpx 16rpx rgba(0, 0, 0, 0.08),
-8rpx -8rpx 16rpx rgba(255, 255, 255, 0.8),
inset 2rpx 2rpx 4rpx rgba(255, 255, 255, 0.5),
inset -2rpx -2rpx 4rpx rgba(0, 0, 0, 0.05);
&.clay-btn-sm {
padding: 12rpx 32rpx;
font-size: 24rpx;
border-radius: 40rpx;
box-shadow:
6rpx 6rpx 12rpx rgba(0, 0, 0, 0.06),
-6rpx -6rpx 12rpx rgba(255, 255, 255, 0.8),
inset 2rpx 2rpx 4rpx rgba(255, 255, 255, 0.5),
inset -2rpx -2rpx 4rpx rgba(0, 0, 0, 0.05);
}
&.clay-btn-md {
padding: 20rpx 48rpx;
font-size: 28rpx;
border-radius: 50rpx;
}
&.clay-btn-lg {
padding: 28rpx 64rpx;
font-size: 32rpx;
border-radius: 60rpx;
box-shadow:
10rpx 10rpx 20rpx rgba(0, 0, 0, 0.1),
-10rpx -10rpx 20rpx rgba(255, 255, 255, 0.7),
inset 3rpx 3rpx 6rpx rgba(255, 255, 255, 0.5),
inset -3rpx -3rpx 6rpx rgba(0, 0, 0, 0.06);
}
/* 按钮变体 */
&.clay-btn-primary {
background: linear-gradient(145deg, #FF9500, #FF6B00);
color: #fff;
box-shadow:
10rpx 10rpx 20rpx rgba(255, 107, 0, 0.15),
-10rpx -10rpx 20rpx rgba(255, 255, 255, 0.7),
inset 3rpx 3rpx 6rpx rgba(255, 255, 255, 0.4),
inset -3rpx -3rpx 6rpx rgba(0, 0, 0, 0.1);
}
&.clay-btn-secondary {
background: linear-gradient(145deg, #ffffff, #f0f0f0);
color: #1D1D1F;
}
&.clay-btn-success {
background: linear-gradient(145deg, #34C759, #30D158);
color: #fff;
box-shadow:
10rpx 10rpx 20rpx rgba(52, 199, 89, 0.15),
-10rpx -10rpx 20rpx rgba(255, 255, 255, 0.7),
inset 3rpx 3rpx 6rpx rgba(255, 255, 255, 0.4),
inset -3rpx -3rpx 6rpx rgba(0, 0, 0, 0.1);
}
&.clay-btn-warning {
background: linear-gradient(145deg, #FF9F0A, #FF9500);
color: #fff;
box-shadow:
10rpx 10rpx 20rpx rgba(255, 159, 10, 0.15),
-10rpx -10rpx 20rpx rgba(255, 255, 255, 0.7),
inset 3rpx 3rpx 6rpx rgba(255, 255, 255, 0.4),
inset -3rpx -3rpx 6rpx rgba(0, 0, 0, 0.1);
}
&.clay-btn-error {
background: linear-gradient(145deg, #FF3B30, #FF453A);
color: #fff;
box-shadow:
10rpx 10rpx 20rpx rgba(255, 59, 48, 0.15),
-10rpx -10rpx 20rpx rgba(255, 255, 255, 0.7),
inset 3rpx 3rpx 6rpx rgba(255, 255, 255, 0.4),
inset -3rpx -3rpx 6rpx rgba(0, 0, 0, 0.1);
}
/* 轮廓样式 */
&.clay-btn-outline {
background: transparent;
border: 3rpx solid currentColor;
&.clay-btn-primary {
color: #FF6B00;
background: rgba(255, 107, 0, 0.05);
}
&.clay-btn-secondary {
color: #86868B;
background: rgba(134, 134, 139, 0.05);
}
}
/* 按下效果 */
&:active {
transform: scale(0.96);
box-shadow:
4rpx 4rpx 8rpx rgba(0, 0, 0, 0.1),
-4rpx -4rpx 8rpx rgba(255, 255, 255, 0.5),
inset 4rpx 4rpx 8rpx rgba(0, 0, 0, 0.08),
inset -4rpx -4rpx 8rpx rgba(255, 255, 255, 0.3);
}
/* 加载状态 */
&.is-loading {
pointer-events: none;
}
}
/* 加载动画 */
.clay-loading {
display: flex;
align-items: center;
justify-content: center;
}
.loading-spinner {
width: 32rpx;
height: 32rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: clay-spin 0.8s linear infinite;
}
@keyframes clay-spin {
to { transform: rotate(360deg); }
}
/* 图标 */
.clay-icon {
font-size: 32rpx;
line-height: 1;
}
/* 文字 */
.clay-text {
line-height: 1;
white-space: nowrap;
}
</style>

151
components/ClayCard.vue Executable file
View File

@ -0,0 +1,151 @@
<template>
<view
class="clay-card"
:class="[
`clay-card-${size}`,
{ 'clay-card-primary': variant === 'primary' },
{ 'clay-card-gold': variant === 'gold' },
{ 'clay-card-inset': inset },
customClass
]"
:style="customStyle"
@tap="handleTap"
>
<slot></slot>
</view>
</template>
<script>
export default {
name: 'ClayCard',
props: {
// sm, md, lg
size: {
type: String,
default: 'md'
},
// default, primary, gold
variant: {
type: String,
default: 'default'
},
//
inset: {
type: Boolean,
default: false
},
//
customClass: {
type: String,
default: ''
},
//
customStyle: {
type: Object,
default: () => ({})
}
},
methods: {
handleTap(e) {
this.$emit('tap', e)
}
}
}
</script>
<style lang="scss" scoped>
/* ============================================
Claymorphism 卡片组件
使用示例
<ClayCard size="lg" variant="primary">内容</ClayCard>
============================================ */
.clay-card {
background: linear-gradient(145deg, #ffffff, #f0f0f0);
border-radius: 24rpx;
position: relative;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
/* 外部双阴影 - 创造凸起效果 */
box-shadow:
8rpx 8rpx 16rpx rgba(0, 0, 0, 0.06),
-8rpx -8rpx 16rpx rgba(255, 255, 255, 0.8),
inset 2rpx 2rpx 4rpx rgba(255, 255, 255, 0.9),
inset -2rpx -2rpx 4rpx rgba(0, 0, 0, 0.03);
&.clay-card-sm {
border-radius: 16rpx;
box-shadow:
6rpx 6rpx 12rpx rgba(0, 0, 0, 0.04),
-6rpx -6rpx 12rpx rgba(255, 255, 255, 0.8),
inset 2rpx 2rpx 4rpx rgba(255, 255, 255, 0.9),
inset -2rpx -2rpx 4rpx rgba(0, 0, 0, 0.03);
}
&.clay-card-md {
border-radius: 24rpx;
}
&.clay-card-lg {
border-radius: 32rpx;
box-shadow:
12rpx 12rpx 24rpx rgba(0, 0, 0, 0.08),
-12rpx -12rpx 24rpx rgba(255, 255, 255, 0.7),
inset 4rpx 4rpx 8rpx rgba(255, 255, 255, 0.85),
inset -4rpx -4rpx 8rpx rgba(0, 0, 0, 0.04);
}
&:active {
transform: scale(0.98);
box-shadow:
4rpx 4rpx 8rpx rgba(0, 0, 0, 0.06),
-4rpx -4rpx 8rpx rgba(255, 255, 255, 0.6),
inset 4rpx 4rpx 8rpx rgba(0, 0, 0, 0.05),
inset -4rpx -4rpx 8rpx rgba(255, 255, 255, 0.4);
}
}
/* 彩色粘土卡片 */
.clay-card-primary {
background: linear-gradient(145deg, #FF9500, #FF6B00);
color: #fff;
box-shadow:
10rpx 10rpx 20rpx rgba(255, 107, 0, 0.2),
-10rpx -10rpx 20rpx rgba(255, 255, 255, 0.7),
inset 3rpx 3rpx 6rpx rgba(255, 255, 255, 0.4),
inset -3rpx -3rpx 6rpx rgba(0, 0, 0, 0.1);
&:active {
box-shadow:
5rpx 5rpx 10rpx rgba(255, 107, 0, 0.25),
-5rpx -5rpx 10rpx rgba(255, 255, 255, 0.5),
inset 5rpx 5rpx 10rpx rgba(0, 0, 0, 0.15),
inset -5rpx -5rpx 10rpx rgba(255, 255, 255, 0.2);
}
}
.clay-card-gold {
background: linear-gradient(145deg, #FFD60A, #FF9F0A);
box-shadow:
10rpx 10rpx 20rpx rgba(255, 159, 10, 0.2),
-10rpx -10rpx 20rpx rgba(255, 255, 255, 0.7),
inset 3rpx 3rpx 6rpx rgba(255, 255, 255, 0.4),
inset -3rpx -3rpx 6rpx rgba(0, 0, 0, 0.1);
&:active {
box-shadow:
5rpx 5rpx 10rpx rgba(255, 159, 10, 0.25),
-5rpx -5rpx 10rpx rgba(255, 255, 255, 0.5),
inset 5rpx 5rpx 10rpx rgba(0, 0, 0, 0.15),
inset -5rpx -5rpx 10rpx rgba(255, 255, 255, 0.2);
}
}
/* 凹陷粘土卡片 (Inset) */
.clay-card-inset {
background: linear-gradient(145deg, #e8e8e8, #f8f8f8);
box-shadow:
inset 6rpx 6rpx 12rpx rgba(0, 0, 0, 0.08),
inset -6rpx -6rpx 12rpx rgba(255, 255, 255, 0.9);
}
</style>

282
components/ClayInput.vue Executable file
View File

@ -0,0 +1,282 @@
<template>
<view
class="clay-input-wrapper"
:class="[
`clay-input-${size}`,
{ 'clay-input-focused': isFocused },
{ 'clay-input-error': error },
{ 'clay-input-disabled': disabled },
customClass
]"
>
<!-- 前缀图标 -->
<view v-if="prefixIcon" class="clay-prefix-icon">
<text>{{ prefixIcon }}</text>
</view>
<!-- 输入框 -->
<input
class="clay-input-field"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:maxlength="maxlength"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@confirm="handleConfirm"
/>
<!-- 后缀图标/按钮 -->
<view v-if="suffixIcon || clearable" class="clay-suffix-icon" @tap="handleSuffixTap">
<text v-if="showClearButton" class="clear-button">×</text>
<text v-else-if="suffixIcon">{{ suffixIcon }}</text>
</view>
<!-- 错误提示 -->
<view v-if="error && errorText" class="clay-error-text">
<text>{{ errorText }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'ClayInput',
props: {
// v-model
modelValue: {
type: [String, Number],
default: ''
},
//
type: {
type: String,
default: 'text'
},
//
placeholder: {
type: String,
default: '请输入'
},
// sm, md, lg
size: {
type: String,
default: 'md'
},
//
disabled: {
type: Boolean,
default: false
},
//
error: {
type: Boolean,
default: false
},
//
errorText: {
type: String,
default: ''
},
//
maxlength: {
type: [String, Number],
default: 140
},
//
clearable: {
type: Boolean,
default: false
},
// emoji
prefixIcon: {
type: String,
default: ''
},
// emoji
suffixIcon: {
type: String,
default: ''
},
//
customClass: {
type: String,
default: ''
}
},
data() {
return {
isFocused: false
}
},
computed: {
showClearButton() {
return this.clearable && this.modelValue && !this.disabled
}
},
methods: {
handleInput(e) {
this.$emit('update:modelValue', e.detail.value)
this.$emit('input', e.detail.value)
},
handleFocus(e) {
this.isFocused = true
this.$emit('focus', e)
},
handleBlur(e) {
this.isFocused = false
this.$emit('blur', e)
},
handleConfirm(e) {
this.$emit('confirm', e)
},
handleSuffixTap() {
if (this.showClearButton) {
this.$emit('update:modelValue', '')
this.$emit('clear')
} else if (this.suffixIcon) {
this.$emit('suffix-tap')
}
}
}
}
</script>
<style lang="scss" scoped>
/* ============================================
Claymorphism 输入框组件
使用示例
<ClayInput v-model="value" placeholder="请输入" prefixIcon="🔍" />
============================================ */
.clay-input-wrapper {
position: relative;
display: flex;
align-items: center;
background: linear-gradient(145deg, #ffffff, #f5f5f5);
border-radius: 24rpx;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
/* Claymorphism 双阴影 */
box-shadow:
inset 4rpx 4rpx 8rpx rgba(0, 0, 0, 0.06),
inset -4rpx -4rpx 8rpx rgba(255, 255, 255, 0.9),
4rpx 4rpx 8rpx rgba(0, 0, 0, 0.04),
-4rpx -4rpx 8rpx rgba(255, 255, 255, 0.7);
&.clay-input-sm {
border-radius: 20rpx;
padding: 16rpx 24rpx;
.clay-input-field {
font-size: 24rpx;
}
}
&.clay-input-md {
border-radius: 24rpx;
padding: 24rpx 28rpx;
.clay-input-field {
font-size: 28rpx;
}
}
&.clay-input-lg {
border-radius: 28rpx;
padding: 28rpx 32rpx;
.clay-input-field {
font-size: 32rpx;
}
}
/* 聚焦状态 */
&.clay-input-focused {
box-shadow:
inset 6rpx 6rpx 12rpx rgba(255, 107, 0, 0.08),
inset -6rpx -6rpx 12rpx rgba(255, 255, 255, 0.85),
6rpx 6rpx 12rpx rgba(255, 107, 0, 0.1),
-6rpx -6rpx 12rpx rgba(255, 255, 255, 0.6);
}
/* 错误状态 */
&.clay-input-error {
box-shadow:
inset 4rpx 4rpx 8rpx rgba(255, 59, 48, 0.1),
inset -4rpx -4rpx 8rpx rgba(255, 255, 255, 0.9),
4rpx 4rpx 8rpx rgba(255, 59, 48, 0.1),
-4rpx -4rpx 8rpx rgba(255, 255, 255, 0.7);
}
/* 禁用状态 */
&.clay-input-disabled {
opacity: 0.5;
background: linear-gradient(145deg, #f0f0f0, #e8e8e8);
}
}
.clay-input-field {
flex: 1;
border: none;
background: transparent;
color: #1D1D1F;
outline: none;
line-height: 1.5;
&::placeholder {
color: #C7C7CC;
}
}
/* 前缀图标 */
.clay-prefix-icon {
margin-right: 16rpx;
font-size: 32rpx;
opacity: 0.6;
}
/* 后缀图标 */
.clay-suffix-icon {
margin-left: 16rpx;
font-size: 32rpx;
opacity: 0.6;
cursor: pointer;
transition: all 0.2s;
&:active {
opacity: 1;
transform: scale(1.1);
}
.clear-button {
display: inline-block;
width: 40rpx;
height: 40rpx;
line-height: 36rpx;
text-align: center;
font-size: 40rpx;
color: #86868B;
background: linear-gradient(145deg, #e8e8e8, #f8f8f8);
border-radius: 50%;
/* Claymorphism 凹陷效果 */
box-shadow:
inset 2rpx 2rpx 4rpx rgba(0, 0, 0, 0.1),
inset -2rpx -2rpx 4rpx rgba(255, 255, 255, 0.9);
}
}
/* 错误提示 */
.clay-error-text {
position: absolute;
bottom: -40rpx;
left: 0;
font-size: 22rpx;
color: #FF3B30;
white-space: nowrap;
}
</style>

0
components/ElCard.vue Normal file → Executable file
View File

26
components/FlipGrid.vue Normal file → Executable file
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,857 @@
<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, 'selected': selectedPkgId === pkg.id }"
@tap="selectPackage(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">{{ (getTotalPrice(pkg) / 100).toFixed(2) }}</text>
</view>
<view class="pkg-original-price" v-if="pkg.original_price > pkg.price">
¥{{ (pkg.original_price * (counts[pkg.id] || 1) / 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>
</view>
</view>
</view>
</scroll-view>
<!-- 优惠券选择区域 -->
<view class="coupon-section" v-if="selectedPkgId">
<view class="section-title">优惠券</view>
<picker
class="coupon-picker"
mode="selector"
:range="couponOptions"
range-key="displayName"
@change="onCouponChange"
:value="couponIndex"
:disabled="!coupons.length"
>
<view class="picker-display">
<view class="picker-left">
<text v-if="selectedCoupon" class="selected-text">
{{ selectedCoupon.name }}
<text class="discount-amount">(-¥{{ effectiveCouponDiscount.toFixed(2) }})</text>
<text v-if="selectedCoupon.balance_amount > maxDeductible * 100" class="cap-hint">(最高抵扣50%)</text>
</text>
<text v-else-if="!coupons.length" class="placeholder">暂无可用优惠券</text>
<text v-else class="placeholder">请选择优惠券</text>
</view>
<text class="arrow"></text>
</view>
</picker>
</view>
<!-- 底部结算区域 -->
<view class="checkout-section" v-if="selectedPkgId">
<view class="checkout-info">
<view class="checkout-total">
<text class="total-label">合计:</text>
<text class="total-price">¥{{ finalPayAmount.toFixed(2) }}</text>
<text v-if="effectiveCouponDiscount > 0" class="saved-amount">已优惠 ¥{{ effectiveCouponDiscount.toFixed(2) }}</text>
</view>
</view>
<button
class="btn-checkout"
:loading="purchasingId === selectedPkgId"
@tap="handlePurchase"
>
立即购买
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getGamePassPackages, purchaseGamePass, createWechatOrder, getUserCoupons } 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({})
const selectedPkgId = ref(null)
//
const coupons = ref([])
const couponIndex = ref(-1)
//
const selectedPkg = computed(() => {
return packages.value.find(p => p.id === selectedPkgId.value) || null
})
// *
function getTotalPrice(pkg) {
const count = counts.value[pkg.id] || 1
return pkg.price * count
}
//
const currentTotalPrice = computed(() => {
if (!selectedPkg.value) return 0
return getTotalPrice(selectedPkg.value)
})
// 50%
const maxDeductible = computed(() => {
return currentTotalPrice.value * 0.5 / 100 //
})
// "使"
const couponOptions = computed(() => {
const noCouponOption = { id: 0, displayName: '不使用优惠券', balance_amount: 0 }
return [noCouponOption, ...coupons.value.map(c => ({
...c,
displayName: `${c.name} (余额: ¥${(c.balance_amount / 100).toFixed(2)})`
}))]
})
//
const selectedCoupon = computed(() => {
if (couponIndex.value <= 0) return null
return coupons.value[couponIndex.value - 1] || null
})
// 50%
const effectiveCouponDiscount = computed(() => {
if (!selectedCoupon.value) return 0
const couponAmt = (selectedCoupon.value.balance_amount || 0) / 100 //
return Math.min(couponAmt, maxDeductible.value)
})
//
const finalPayAmount = computed(() => {
const total = currentTotalPrice.value / 100 //
return Math.max(0, total - effectiveCouponDiscount.value)
})
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
}
}
function selectPackage(pkg) {
selectedPkgId.value = pkg.id
//
couponIndex.value = 0
}
function onCouponChange(e) {
couponIndex.value = e.detail.value
}
watch(() => props.visible, (val) => {
if (val) {
fetchPackages()
fetchCoupons()
} else {
//
selectedPkgId.value = null
couponIndex.value = 0
}
})
async function fetchPackages() {
loading.value = true
try {
const res = await getGamePassPackages(props.activityId)
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
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 }
})
//
if (packages.value.length > 0) {
selectedPkgId.value = packages.value[0].id
}
} catch (e) {
console.error(e)
packages.value = []
} finally {
loading.value = false
}
}
async function fetchCoupons() {
try {
const userId = uni.getStorageSync('user_id')
if (!userId) return
const res = await getUserCoupons(userId, 0, 1, 20) // status=0
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.coupons)) list = res.coupons
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
// (scope_type = 1) (discount_type = 1)
//
// balance_amount remaining
coupons.value = list.filter(c => {
// scope_type=1
// scope_type
if (c.scope_type !== undefined && c.scope_type !== 1) return false
// balance_amount remaining
const balance = c.balance_amount ?? c.remaining ?? c.amount
if (!balance || balance <= 0) return false
return true
}).map(c => ({
// balance_amount 使
...c,
balance_amount: c.balance_amount ?? c.remaining ?? c.amount
}))
console.log('获取到的优惠券列表:', coupons.value)
} catch (e) {
console.error('获取优惠券失败:', e)
coupons.value = []
}
}
async function handlePurchase() {
if (!selectedPkgId.value) {
uni.showToast({ title: '请选择套餐', icon: 'none' })
return
}
const pkg = selectedPkg.value
if (!pkg) return
if (purchasingId.value) return
purchasingId.value = pkg.id
try {
uni.showLoading({ title: '创建订单...' })
const count = counts.value[pkg.id] || 1
const couponIds = selectedCoupon.value ? [selectedCoupon.value.id] : []
const res = await purchaseGamePass(pkg.id, count, couponIds)
const orderNo = res.order_no || res.orderNo
// 0
if (finalPayAmount.value <= 0) {
uni.showToast({ title: '购买成功', icon: 'success' })
emit('success')
handleClose()
return
}
if (!orderNo) throw new Error('下单失败')
//
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: $gradient-brand;
-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: $brand-primary;
transform: rotate(90deg);
}
}
}
.packages-list {
padding: 32rpx 24rpx 16rpx;
max-height: 42vh;
flex-shrink: 0;
}
.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: $gradient-brand;
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: $gradient-brand;
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: $brand-primary;
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: $brand-primary;
background: rgba($brand-primary, 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;
}
}
}
//
.package-item.selected {
border-color: $brand-primary;
background: linear-gradient(145deg, #FFF8F4 0%, #FFF0E6 100%);
box-shadow: 0 4rpx 20rpx rgba($brand-primary, 0.2);
&::before {
opacity: 1;
}
}
//
.coupon-section {
padding: 0 24rpx 20rpx;
border-top: 2rpx solid #F0F2F5;
margin-top: 0;
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #374151;
margin-bottom: 16rpx;
padding-top: 20rpx;
}
.coupon-picker {
width: 100%;
}
.picker-display {
background: linear-gradient(145deg, #F8F9FF 0%, #FFFFFF 100%);
border: 2rpx solid #E8EEFF;
border-radius: 16rpx;
padding: 20rpx 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
&:active {
border-color: #667EEA;
background: #F0F3FF;
}
}
.picker-left {
flex: 1;
overflow: hidden;
}
.selected-text {
font-size: 28rpx;
color: #1F2937;
font-weight: 500;
.discount-amount {
color: #10B981;
font-weight: 600;
margin-left: 8rpx;
}
.cap-hint {
font-size: 22rpx;
color: #F59E0B;
margin-left: 8rpx;
}
}
.placeholder {
font-size: 28rpx;
color: #9CA3AF;
}
.arrow {
width: 16rpx;
height: 16rpx;
border-right: 3rpx solid #9CA3AF;
border-bottom: 3rpx solid #9CA3AF;
transform: rotate(-45deg);
margin-left: 16rpx;
flex-shrink: 0;
}
}
//
.checkout-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, #FFFFFF 0%, #F8F9FF 100%);
border-top: 2rpx solid #F0F2F5;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
.checkout-info {
flex: 1;
}
.checkout-total {
display: flex;
align-items: baseline;
flex-wrap: wrap;
}
.total-label {
font-size: 28rpx;
color: #6B7280;
margin-right: 8rpx;
}
.total-price {
font-size: 48rpx;
font-weight: 800;
color: #FF6B6B;
letter-spacing: -1rpx;
}
.saved-amount {
font-size: 22rpx;
color: #10B981;
background: rgba(16, 185, 129, 0.1);
padding: 4rpx 12rpx;
border-radius: 20rpx;
margin-left: 12rpx;
font-weight: 500;
}
.btn-checkout {
background: $gradient-brand;
color: #FFF;
font-size: 32rpx;
padding: 0 48rpx;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
border: none;
font-weight: 700;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;
letter-spacing: 1rpx;
&[loading] {
opacity: 0.8;
}
&:active {
transform: scale(0.95);
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.5);
}
&::after {
border: none;
}
}
}
</style>

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 Executable 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>

746
components/PaymentPopup.vue Normal file → Executable file
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 ? 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>

190
components/PrivacyPopup.vue Normal file
View File

@ -0,0 +1,190 @@
<template>
<view v-if="showPrivacy" class="privacy-popup-mask">
<view class="privacy-popup-content">
<view class="privacy-title">用户隐私保护提示</view>
<view class="privacy-desc">
感谢您使用柯大鸭盲盒小程序我们非常重视您的隐私保护和用户权益
<br /><br />
在您使用我们的服务前请您仔细阅读并充分理解
<text class="privacy-link" @tap="openPrivacyContract">用户隐私保护指引</text>
<br /><br />
当您点击同意并开始使用我们的服务时即表示您已理解并同意该指引内容我们将按照指引内容处理您的个人信息
</view>
<view class="privacy-buttons">
<button class="btn-reject" @tap="handleDisagree">拒绝</button>
<button
class="btn-agree"
id="agree-btn"
open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="handleAgree"
>
同意
</button>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'PrivacyPopup',
data() {
return {
showPrivacy: false,
resolvePrivacyAuthorization: null
}
},
mounted() {
// #ifdef MP-WEIXIN
this.initPrivacy()
// #endif
},
methods: {
initPrivacy() {
// #ifdef MP-WEIXIN
wx.onNeedPrivacyAuthorization((resolve, eventInfo) => {
console.log('[隐私协议] 触发隐私授权', eventInfo)
this.resolvePrivacyAuthorization = resolve
this.showPrivacy = true
})
wx.getPrivacySetting({
success: (res) => {
console.log('[隐私协议] 隐私设置', res)
if (res.needAuthorization) {
//
} else {
//
this.showPrivacy = false
}
},
fail: (err) => {
console.error('[隐私协议] 获取隐私设置失败', err)
}
})
// #endif
},
openPrivacyContract() {
// #ifdef MP-WEIXIN
wx.openPrivacyContract({
success: () => {
console.log('[隐私协议] 打开隐私协议成功')
},
fail: (err) => {
console.error('[隐私协议] 打开隐私协议失败', err)
}
})
// #endif
},
handleAgree() {
console.log('[隐私协议] 用户同意')
this.showPrivacy = false
if (this.resolvePrivacyAuthorization) {
this.resolvePrivacyAuthorization({
event: 'agree',
buttonId: 'agree-btn'
})
this.resolvePrivacyAuthorization = null
}
this.$emit('agree')
},
handleDisagree() {
console.log('[隐私协议] 用户拒绝')
this.showPrivacy = false
if (this.resolvePrivacyAuthorization) {
this.resolvePrivacyAuthorization({
event: 'disagree'
})
this.resolvePrivacyAuthorization = null
}
this.$emit('disagree')
uni.showModal({
title: '提示',
content: '您拒绝了隐私协议,部分功能可能无法正常使用',
showCancel: false
})
}
}
}
</script>
<style lang="scss" scoped>
.privacy-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.privacy-popup-content {
width: 600rpx;
background: #fff;
border-radius: 24rpx;
padding: 48rpx 40rpx;
box-sizing: border-box;
}
.privacy-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 32rpx;
}
.privacy-desc {
font-size: 28rpx;
color: #666;
line-height: 1.8;
margin-bottom: 40rpx;
}
.privacy-link {
color: #FF6B00;
text-decoration: underline;
}
.privacy-buttons {
display: flex;
gap: 24rpx;
}
.btn-reject {
flex: 1;
height: 88rpx;
line-height: 88rpx;
font-size: 30rpx;
color: #666;
background: #f5f5f5;
border: none;
border-radius: 44rpx;
&::after {
border: none;
}
}
.btn-agree {
flex: 1;
height: 88rpx;
line-height: 88rpx;
font-size: 30rpx;
color: #fff;
background: linear-gradient(135deg, #FF9500, #FF6B00);
border: none;
border-radius: 44rpx;
&::after {
border: none;
}
}
</style>

204
components/SplashScreen.vue Executable 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>

295
components/YifanSelector.vue Normal file → Executable file
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,66 +41,110 @@
</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([])
const coupons = ref([])
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,33 +203,48 @@ 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() {
const available = choices.value.filter(item =>
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)
)
if (available.length === 0) {
uni.showToast({ title: '没有可选位置了', icon: 'none' })
return
}
const randomIndex = Math.floor(Math.random() * available.length)
const randomItem = available[randomIndex]
//
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,289 @@
<template>
<view v-if="visible && activity" class="prize-claim-overlay" @touchmove.stop.prevent>
<view class="prize-claim-mask" @tap="handleClose"></view>
<view class="prize-claim-panel" @tap.stop>
<view class="prize-claim-hero">
<view class="hero-glow"></view>
<view class="hero-top">
<view class="hero-badge">奖励发放</view>
<text class="prize-claim-close" @tap="handleClose">×</text>
</view>
<view class="hero-content">
<text class="hero-title">奖励已到账待你领取</text>
<text class="hero-reason">{{ activity.reason }}</text>
</view>
</view>
<view class="prize-claim-body">
<view class="section-title">奖品内容</view>
<view class="reward-list">
<view
v-for="(item, index) in activity.rewards || []"
:key="`${item.reward_type}-${item.reward_ref_id}-${index}`"
class="reward-item"
>
<view class="reward-thumb-wrap">
<image v-if="item.image" class="reward-thumb" :src="item.image" mode="aspectFill" />
<view v-else class="reward-thumb-empty">{{ typeShortLabel(item.reward_type) }}</view>
</view>
<view class="reward-main">
<text class="reward-name">{{ item.name || item.reward_type }}</text>
<view class="reward-meta-row">
<text class="reward-type">{{ typeLabel(item.reward_type) }}</text>
<text v-if="item.value_cents > 0" class="reward-value">单价 ¥{{ (item.value_cents / 100).toFixed(2) }}</text>
</view>
</view>
<view class="reward-side">
<text class="reward-quantity">x{{ item.quantity }}</text>
</view>
</view>
</view>
</view>
<view class="prize-claim-footer">
<button class="claim-button" :disabled="loading" @tap="handleClaim">
{{ loading ? '领取中...' : '立即领取' }}
</button>
</view>
</view>
</view>
</template>
<script setup>
const props = defineProps({
visible: { type: Boolean, default: false },
activity: { type: Object, default: null },
loading: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible', 'claim', 'close'])
function handleClose() {
emit('close')
emit('update:visible', false)
}
function handleClaim() {
if (!props.loading) emit('claim')
}
function typeLabel(type) {
if (type === 'product') return '商品'
if (type === 'coupon') return '优惠券'
if (type === 'item_card') return '道具卡'
return type
}
function typeShortLabel(type) {
if (type === 'product') return '商品'
if (type === 'coupon') return '券'
if (type === 'item_card') return '卡'
return '奖'
}
</script>
<style lang="scss" scoped>
.prize-claim-overlay {
position: fixed;
inset: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
}
.prize-claim-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6rpx);
}
.prize-claim-panel {
position: relative;
width: 88%;
max-height: 78vh;
background: $bg-card;
border-radius: $radius-xl;
overflow: hidden;
box-shadow: $shadow-lg;
animation: slideUp 0.25s ease-out;
}
.prize-claim-hero {
position: relative;
padding: $spacing-lg $spacing-lg $spacing-xl;
background: linear-gradient(135deg, $brand-primary 0%, $brand-primary-light 100%);
}
.hero-glow {
position: absolute;
right: -80rpx;
top: -80rpx;
width: 260rpx;
height: 260rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
}
.hero-top {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $spacing-lg;
}
.hero-badge {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
color: #fff;
font-size: $font-xs;
font-weight: 700;
}
.prize-claim-close {
color: rgba(255, 255, 255, 0.9);
font-size: 48rpx;
line-height: 1;
}
.hero-content {
position: relative;
}
.hero-title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #fff;
line-height: 1.3;
margin-bottom: 12rpx;
}
.hero-reason {
display: block;
font-size: $font-md;
color: rgba(255, 255, 255, 0.92);
line-height: 1.6;
}
.prize-claim-body {
padding: $spacing-lg;
}
.section-title {
font-size: $font-md;
font-weight: 700;
color: $text-main;
margin-bottom: $spacing-md;
}
.reward-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.reward-item {
display: flex;
align-items: center;
gap: $spacing-md;
background: $bg-page;
border-radius: $radius-lg;
padding: $spacing-md;
}
.reward-thumb-wrap {
width: 112rpx;
height: 112rpx;
border-radius: $radius-md;
overflow: hidden;
flex-shrink: 0;
background: #fff;
box-shadow: $shadow-sm;
}
.reward-thumb {
width: 100%;
height: 100%;
}
.reward-thumb-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-sm;
color: $text-sub;
background: rgba($brand-primary, 0.08);
}
.reward-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
min-width: 0;
}
.reward-name {
font-size: $font-md;
color: $text-main;
font-weight: 700;
line-height: 1.4;
}
.reward-meta-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.reward-type,
.reward-value,
.reward-quantity {
font-size: $font-sm;
color: $text-sub;
}
.reward-value {
color: $brand-primary-dark;
font-weight: 600;
}
.reward-side {
display: flex;
align-items: center;
justify-content: center;
min-width: 64rpx;
}
.reward-quantity {
font-size: $font-md;
font-weight: 700;
color: $brand-primary;
}
.prize-claim-footer {
padding: 0 $spacing-lg $spacing-lg;
}
.claim-button {
width: 100%;
border: none;
border-radius: $radius-round;
background: linear-gradient(135deg, $brand-primary, $brand-primary-light);
color: #fff;
font-size: $font-md;
font-weight: 700;
padding: 24rpx 0;
box-shadow: $shadow-warm;
}
.claim-button[disabled] {
opacity: 0.65;
}
</style>

View File

@ -0,0 +1,238 @@
<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>
</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" v-if="item.count > 1">x{{ item.count }}</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: '今日暂无购买记录记录不会展示最近5分钟内的'
}
})
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,379 @@
<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>
<!-- 概率总览条 -->
<view class="prob-overview" v-if="rewardGroups.length > 0">
<view
class="prob-item"
v-for="group in rewardGroups"
:key="'prob-' + group.level"
>
<view class="prob-dot" :class="getDotClass(group.level)"></view>
<text class="prob-label">{{ group.level }}</text>
<text class="prob-value">{{ group.totalPercent }}%</text>
</view>
</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="getBadgeClass(group.level)">{{ 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">
<view class="thumb-wrap">
<image class="rewards-thumb" :src="item.image" mode="aspectFill" />
<view class="thumb-level-tag" :class="getBadgeClass(group.level)">{{ group.level }}</view>
</view>
<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-price">参考价¥{{ item.product_price > 0 ? (item.product_price / 100).toFixed(2) : '--' }}</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'])
/** 概率总览圆点颜色 class */
function getDotClass(level) {
if (level === 'BOSS') return 'dot-boss'
if (level === 'S' || level === 'Last') return 'dot-rare'
if (level === 'A') return 'dot-a'
return 'dot-normal'
}
/** 分组标签颜色 class */
function getBadgeClass(level) {
if (level === 'BOSS') return 'badge-boss'
if (level === 'S' || level === 'Last') return 'badge-rare'
if (level === 'A') return 'badge-a'
return ''
}
</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;
}
/* ============================================
概率总览条
============================================ */
.prob-overview {
display: flex;
flex-wrap: wrap;
gap: $spacing-sm $spacing-lg;
padding: $spacing-md $spacing-lg;
background: rgba(0, 0, 0, 0.02);
border-bottom: 1rpx solid $border-color-light;
}
.prob-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.prob-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
&.dot-boss {
background: $accent-gold;
box-shadow: 0 0 8rpx rgba(255, 193, 7, 0.5);
}
&.dot-rare {
background: $brand-primary;
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4);
}
&.dot-a {
background: $accent-orange;
}
&.dot-normal {
background: $text-tertiary;
}
}
.prob-label {
font-size: $font-xs;
font-weight: 600;
color: $text-main;
}
.prob-value {
font-size: $font-xs;
font-weight: 800;
color: $brand-primary;
}
/* ============================================
奖品列表
============================================ */
.rewards-list {
max-height: 55vh;
padding: $spacing-lg;
}
.rewards-group-v2 {
margin-bottom: $spacing-lg;
background: rgba(0, 0, 0, 0.02);
padding: $spacing-md;
border-radius: $radius-lg;
&:last-child {
margin-bottom: 0;
}
}
.group-header-row {
display: flex;
align-items: center;
justify-content: space-between;
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;
}
&.badge-rare {
background: $gradient-brand;
color: #fff;
}
&.badge-a {
background: rgba($accent-orange, 0.15);
color: $accent-orange;
}
}
.group-total-prob {
font-size: $font-xs;
color: $text-sub;
font-weight: 600;
}
.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;
}
}
.thumb-wrap {
position: relative;
flex-shrink: 0;
margin-right: $spacing-md;
}
.rewards-thumb {
width: 120rpx;
height: 120rpx;
border-radius: $radius-md;
background: $bg-secondary;
display: block;
}
.thumb-level-tag {
position: absolute;
left: 0;
bottom: 0;
font-size: 20rpx;
font-weight: 700;
padding: 2rpx 10rpx;
border-radius: 0 $radius-md 0 $radius-md;
color: $brand-primary;
background: rgba($brand-primary, 0.12);
&.badge-boss {
background: $gradient-gold;
color: #6b4b1f;
}
&.badge-rare {
background: $gradient-brand;
color: #fff;
}
&.badge-a {
background: rgba($accent-orange, 0.15);
color: $accent-orange;
}
}
.rewards-info {
flex: 1;
min-width: 0;
}
.rewards-name-row {
display: flex;
align-items: center;
gap: $spacing-xs;
margin-bottom: 6rpx;
}
.rewards-name {
font-size: $font-md;
font-weight: 600;
color: $text-main;
@include text-ellipsis(2);
}
.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-price {
font-size: $font-md;
font-weight: 700;
color: $brand-primary;
display: block;
margin-bottom: 4rpx;
}
.rewards-percent {
font-size: $font-sm;
color: $text-sub;
}
.rewards-qty-tag {
font-size: $font-xxs;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff6b35, #ff4500);
padding: 2rpx 10rpx;
border-radius: $radius-sm;
flex-shrink: 0;
}
.rewards-empty {
text-align: center;
color: $text-sub;
padding: $spacing-xl;
font-size: $font-sm;
}
</style>

View File

@ -0,0 +1,278 @@
<template>
<view>
<view class="section-header">
<text class="section-title">{{ title }}</text>
<!-- 通过 hideViewAll 控制是否显示查看全部按钮 -->
<text v-if="!hideViewAll" class="section-more" @tap="$emit('view-all')">查看全部</text>
</view>
<view class="tip-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: '暂无奖品配置'
},
hideViewAll: {
type: Boolean,
default: false
}
})
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;
}
}
.tip-text {
font-size: 22rpx;
color: #E67E22;
margin-bottom: $spacing-md;
background: rgba(230, 126, 34, 0.08);
padding: 12rpx 16rpx;
border-radius: 8rpx;
border-left: 4rpx solid rgba(230, 126, 34, 0.5);
}
/* 等级分组 */
.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);
}
}
.drop-qty-badge {
position: absolute;
top: 10rpx;
right: 10rpx;
background: linear-gradient(135deg, #ff6b35, #ff4500);
color: #fff;
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 16rpx;
z-index: 10;
font-weight: 700;
box-shadow: 0 2rpx 6rpx rgba(255, 69, 0, 0.3);
}
/* 空状态 */
.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>

11
components/activity/index.js Executable file
View File

@ -0,0 +1,11 @@
/**
* 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'
export { default as PrizeClaimPopup } from './PrizeClaimPopup.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>

170
components/app-tab-bar.vue Executable file
View File

@ -0,0 +1,170 @@
<template>
<!-- #ifndef MP-TOUTIAO -->
<view class="clay-tab-bar">
<view class="tab-bar-item" @tap="switchTab('pages/index/index')" :class="{ active: selected === 0 }">
<view class="tab-icon-wrapper" :class="{ 'icon-active': selected === 0 }">
<image class="tab-icon" :src="selected === 0 ? '/static/tab/home_active.png' : '/static/tab/home.png'" mode="aspectFit"></image>
</view>
<text class="tab-text" :class="{ active: selected === 0 }">首页</text>
</view>
<view class="tab-bar-item" @tap="switchTab('pages/shop/index')" :class="{ active: selected === 1 }">
<view class="tab-icon-wrapper" :class="{ 'icon-active': selected === 1 }">
<image class="tab-icon" :src="selected === 1 ? '/static/tab/shop_active.png' : '/static/tab/shop.png'" mode="aspectFit"></image>
</view>
<text class="tab-text" :class="{ active: selected === 1 }">商城</text>
</view>
<view class="tab-bar-item" @tap="switchTab('pages/cabinet/index')" :class="{ active: selected === 2 }">
<view class="tab-icon-wrapper" :class="{ 'icon-active': selected === 2 }">
<image class="tab-icon" :src="selected === 2 ? '/static/tab/box_active.png' : '/static/tab/box.png'" mode="aspectFit"></image>
</view>
<text class="tab-text" :class="{ active: selected === 2 }">盒柜</text>
</view>
<view class="tab-bar-item" @tap="switchTab('pages/mine/index')" :class="{ active: selected === 3 }">
<view class="tab-icon-wrapper" :class="{ 'icon-active': selected === 3 }">
<image class="tab-icon" :src="selected === 3 ? '/static/tab/profile_active.png' : '/static/tab/profile.png'" mode="aspectFit"></image>
</view>
<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 */
/* ============================================
Claymorphism 底部导航栏
粘土风格 - 柔和浮感 & 双阴影效果
============================================ */
.clay-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(145deg, #ffffff, #f5f5f5);
border-top: 1px solid rgba(255, 255, 255, 0.6);
display: flex;
justify-content: space-around;
align-items: center;
padding: 12rpx 0 calc(12rpx + env(safe-area-inset-bottom));
z-index: 999;
/* Claymorphism 双阴影 - 创造浮起效果 */
box-shadow:
0 -8rpx 16rpx rgba(0, 0, 0, 0.04),
0 8rpx 16rpx rgba(255, 255, 255, 0.8),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.02);
}
.tab-bar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8rpx 0;
position: relative;
transition: all 0.3s ease;
&.active {
.tab-icon-wrapper {
/* 选中状态 - Claymorphism 凸起效果 */
background: linear-gradient(145deg, #FF9500, #FF6B00);
box-shadow:
6rpx 6rpx 12rpx rgba(255, 107, 0, 0.2),
-6rpx -6rpx 12rpx rgba(255, 255, 255, 0.7),
inset 2rpx 2rpx 4rpx rgba(255, 255, 255, 0.4),
inset -2rpx -2rpx 4rpx rgba(0, 0, 0, 0.1);
transform: translateY(-4rpx);
}
.tab-text {
color: #FF6B00;
font-weight: 700;
}
}
&:active {
transform: scale(0.95);
}
}
/* 图标包装器 - Claymorphism 圆形徽章 */
.tab-icon-wrapper {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6rpx;
background: linear-gradient(145deg, #f8f8f8, #e8e8e8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* 默认状态 - 凹陷效果 */
box-shadow:
inset 3rpx 3rpx 6rpx rgba(0, 0, 0, 0.08),
inset -3rpx -3rpx 6rpx rgba(255, 255, 255, 0.9);
}
.tab-icon {
width: 40rpx;
height: 40rpx;
transition: all 0.3s ease;
}
.tab-text {
font-size: 22rpx;
color: #86868B;
transition: all 0.3s ease;
font-weight: 500;
}
/* 选中状态的图标颜色调整 */
.tab-bar-item.active .tab-icon {
filter: brightness(0) invert(1);
}
/* #endif */
</style>

69
components/clay-components.js Executable file
View File

@ -0,0 +1,69 @@
/**
* Claymorphism 组件库导出
*
* 使用方式 pages.json 中配置 easycom
* {
* "easycom": {
* "autoscan": true,
* "custom": {
* "^Clay(A.*)": "@/components/Clay$1.vue"
* }
* }
* }
*
* 或手动导入
* import ClayCard from '@/components/ClayCard.vue'
* import ClayButton from '@/components/ClayButton.vue'
* import ClayInput from '@/components/ClayInput.vue'
*/
export { default as ClayCard } from './ClayCard.vue'
export { default as ClayButton } from './ClayButton.vue'
export { default as ClayInput } from './ClayInput.vue'
/* ============================================
Claymorphism 设计系统说明
🎨 核心特点
1. 双阴影效果外部 + 内部创造立体浮感
2. 柔和的渐变背景
3. 圆润的边角设计
4. 有机感的按压动画
📦 组件列表
- ClayCard: 粘土风格卡片
- ClayButton: 粘土风格按钮
- ClayInput: 粘土风格输入框
🎯 使用示例
<!-- 卡片 -->
<ClayCard size="lg" variant="primary" @tap="handleTap">
<text>卡片内容</text>
</ClayCard>
<!-- 按钮 -->
<ClayButton
text="确认"
variant="primary"
size="lg"
:loading="isLoading"
@tap="handleConfirm"
/>
<!-- 输入框 -->
<ClayInput
v-model="inputValue"
placeholder="请输入内容"
prefixIcon="🔍"
:clearable="true"
@confirm="handleSearch"
/>
🎨 样式变量uni.scss
- $clay-shadow-sm/md/lg/xl: 阴影层级
- $clay-bg-light: #FAFAFA
- $clay-bg-white: #FFFFFF
- $clay-border: rgba(255, 255, 255, 0.8)
============================================ */

260
docs/CLAYMORPHISM.md Executable file
View File

@ -0,0 +1,260 @@
# Claymorphism UI 优化文档
## 🎨 设计系统概述
### 什么是 Claymorphism粘土拟态
Claymorphism 是一种结合了 **Neumorphism新拟态****3D 粘土质感** 的设计风格,具有以下特点:
- ✨ **双阴影效果**:外部阴影 + 内部阴影创造立体浮感
- 🎨 **柔和渐变**:使用 145deg 渐变创造有机感
- 🔄 **圆润边角**:大圆角设计传递柔和友好感
- 💫 **有机动画**:平滑的过渡和按压反馈
- 🌈 **高对比度**:保持可访问性的同时提供视觉吸引力
---
## 📦 组件库
### 核心组件
| 组件 | 文件路径 | 功能 |
|------|---------|------|
| **ClayCard** | `/components/ClayCard.vue` | 粘土风格卡片 |
| **ClayButton** | `/components/ClayButton.vue` | 粘土风格按钮 |
| **ClayInput** | `/components/ClayInput.vue` | 粘土风格输入框 |
### 使用示例
```vue
<template>
<view>
<!-- 卡片组件 -->
<ClayCard size="lg" variant="primary" @tap="handleTap">
<text>这是一张粘土卡片</text>
</ClayCard>
<!-- 按钮组件 -->
<ClayButton
text="确认操作"
variant="primary"
size="lg"
:loading="isLoading"
@tap="handleConfirm"
/>
<!-- 输入框组件 -->
<ClayInput
v-model="searchText"
placeholder="搜索内容..."
prefixIcon="🔍"
:clearable="true"
@confirm="handleSearch"
/>
</view>
</template>
<script>
import { ClayCard, ClayButton, ClayInput } from '@/components/clay-components.js'
export default {
components: { ClayCard, ClayButton, ClayInput },
data() {
return {
searchText: '',
isLoading: false
}
},
methods: {
handleTap() { console.log('卡片被点击') },
async handleConfirm() {
this.isLoading = true
// 执行操作...
},
handleSearch(val) { console.log('搜索:', val) }
}
}
</script>
```
---
## 🎯 组件 Props
### ClayCard
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `size` | String | `'md'` | 尺寸:`sm`, `md`, `lg` |
| `variant` | String | `'default'` | 变体:`default`, `primary`, `gold` |
| `inset` | Boolean | `false` | 是否凹陷效果 |
| `customClass` | String | `''` | 自定义类名 |
| `customStyle` | Object | `{}` | 自定义样式 |
### ClayButton
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `text` | String | `'按钮'` | 按钮文字 |
| `size` | String | `'md'` | 尺寸:`sm`, `md`, `lg` |
| `variant` | String | `'primary'` | 变体:`primary`, `secondary`, `success`, `warning`, `error` |
| `outline` | Boolean | `false` | 是否轮廓样式 |
| `block` | Boolean | `false` | 是否块级按钮 |
| `disabled` | Boolean | `false` | 是否禁用 |
| `loading` | Boolean | `false` | 是否加载中 |
| `icon` | String | `''` | 图标emoji |
| `customStyle` | Object | `{}` | 自定义样式 |
### ClayInput
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `modelValue` | String/Number | `''` | v-model 绑定值 |
| `type` | String | `'text'` | 输入框类型 |
| `placeholder` | String | `'请输入'` | 占位符 |
| `size` | String | `'md'` | 尺寸:`sm`, `md`, `lg` |
| `disabled` | Boolean | `false` | 是否禁用 |
| `error` | Boolean | `false` | 是否错误状态 |
| `errorText` | String | `''` | 错误提示 |
| `clearable` | Boolean | `false` | 是否可清空 |
| `prefixIcon` | String | `''` | 前缀图标emoji |
| `suffixIcon` | String | `''` | 后缀图标emoji |
---
## 🎨 样式变量
`uni.scss` 中定义的核心变量:
```scss
/* Claymorphism 阴影层级 */
$clay-shadow-sm: (...)
$clay-shadow-md: (...)
$clay-shadow-lg: (...)
$clay-shadow-xl: (...)
/* 颜色变量 */
$clay-bg-light: #FAFAFA;
$clay-bg-white: #FFFFFF;
$clay-bg-soft: rgba(255, 255, 255, 0.85);
$clay-border: rgba(255, 255, 255, 0.8);
```
---
## 🔧 已优化的页面
### ✅ 首页 (`pages/index/index.vue`)
- Banner 轮播卡片
- 通知栏
- 玩法分类卡片
- 活动列表项
### ✅ 底部导航栏 (`components/app-tab-bar.vue`)
- 导航栏背景
- 图标容器(圆形徽章)
- 选中状态动画
### ✅ 个人中心 (`pages/mine/index.vue`)
- 用户信息卡片
- 头像凹陷效果
- 统计数据卡片
- 功能图标容器
---
## 🚀 快速开始
### 1. 配置 easycom推荐
`pages.json` 中添加:
```json
{
"easycom": {
"autoscan": true,
"custom": {
"^Clay(A.*)": "@/components/Clay$1.vue"
}
}
}
```
### 2. 直接使用组件
```vue
<template>
<ClayCard size="lg">内容</ClayCard>
<ClayButton text="按钮" variant="primary" />
<ClayInput v-model="value" placeholder="输入..." />
</template>
```
### 3. 使用全局样式类
```vue
<template>
<!-- 粘土卡片 -->
<view class="clay-card">内容</view>
<!-- 粘土按钮 -->
<view class="clay-btn clay-btn-primary clay-btn-md">按钮</view>
<!-- 凹陷效果 -->
<view class="clay-card-inset">凹陷卡片</view>
</template>
```
---
## 🎨 设计原则
### ✅ 推荐做法
1. **保持一致性**:在整个应用中统一使用 Claymorphism 组件
2. **适度使用**:不要过度使用,保持界面呼吸感
3. **注意对比度**:确保文字与背景有足够的对比度
4. **动画平滑**:使用 `cubic-bezier(0.4, 0, 0.2, 1)` 缓动函数
5. **圆角统一**:卡片 24-32rpx按钮 50-60rpx圆角
### ❌ 避免做法
1. **不要混合多种拟态风格**
2. **不要使用过多的彩色渐变**
3. **不要忽略深色模式适配**
4. **不要在小元素上使用粘土效果**
---
## 📱 兼容性
| 平台 | 支持情况 |
|------|----------|
| 微信小程序 | ✅ 完全支持 |
| 抖音小程序 | ✅ 完全支持 |
| H5 | ✅ 完全支持 |
| App | ✅ 完全支持 |
---
## 🔮 后续计划
- [ ] 添加更多组件ClaySwitch、ClaySlider、ClayModal
- [ ] 深色模式适配
- [ ] 动画效果增强
- [ ] 性能优化
---
## 📝 更新日志
### v1.0.0 (2025-02-05)
- ✅ 初始化 Claymorphism 设计系统
- ✅ 创建核心组件ClayCard、ClayButton、ClayInput
- ✅ 优化首页、个人中心、底部导航栏
- ✅ 添加全局样式变量和 Mixins
---
**设计团队**: Z Code AI Studio
**最后更新**: 2025-02-05

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*

0
index.html Normal file → Executable file
View File

14
main.js Normal file → Executable file
View File

@ -15,6 +15,20 @@ app.$mount()
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
// #ifdef MP-TOUTIAO
// 抖音小程序:忽略开发工具日志通道的 socket 错误
const originalError = console.error
console.error = function(...args) {
const message = args[0]
if (typeof message === 'string' && message.includes('APIScopeError: socket does not exist')) {
// 忽略这个已知的开发工具错误
return
}
originalError.apply(console, args)
}
// #endif
return {
app
}

48
manifest.json Normal file → Executable file
View File

@ -1,6 +1,6 @@
{
"name" : "app_client",
"appid" : "",
"appid" : "__UNI__07C684D",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
@ -17,7 +17,9 @@
"delay" : 0
},
/* */
"modules" : {},
"modules" : {
"Payment" : {}
},
/* */
"distribute" : {
/* android */
@ -37,13 +39,33 @@
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>"
]
},
/* ios */
"ios" : {},
"ios" : {
"dSYMs" : false
},
/* SDK */
"sdkConfigs" : {}
"sdkConfigs" : {
"payment" : {
"weixin" : {
"__platform__" : ["android", "ios"],
"appid" : "wx26ad074017e1e63f",
"UniversalLinks" : ""
}
},
"share" : {},
"oauth" : {
"weixin" : {
"appid" : "wx26ad074017e1e63f",
"UniversalLinks" : ""
}
}
}
}
},
/* */
@ -57,7 +79,11 @@
"es6" : true,
"postcss" : true
},
"usingComponents" : true
"usingComponents" : true,
"lazyCodeLoading" : "requiredComponents",
"optimization" : {
"subPackages" : true
}
},
"mp-alipay" : {
"usingComponents" : true
@ -67,7 +93,15 @@
},
"mp-toutiao" : {
"usingComponents" : true,
"appid" : "ttf031868c6f33d91001"
"appid" : "ttf031868c6f33d91001",
"privacy" : {
"getPhoneNumber" : {
"desc" : "用于登录和账号绑定"
}
},
"optimization" : {
"subPackages" : true
}
},
"uniStatistics" : {
"enable" : false

File diff suppressed because it is too large Load Diff

View File

@ -2,17 +2,36 @@
<view class="page">
<scroll-view class="content" scroll-y>
<view v-if="loading" class="loading-wrap"><view class="spinner"></view></view>
<view v-else-if="filteredActivities.length > 0" class="activity-grid">
<view class="activity-item" v-for="a in filteredActivities" :key="a.id" @tap="onActivityTap(a)">
<view class="thumb-box">
<image class="thumb" :src="a.image" mode="aspectFill" />
<view class="tag-hot">HOT</view>
<view v-else-if="filteredActivities.length > 0" class="waterfall-box">
<!-- 左列 -->
<view class="waterfall-col">
<view class="activity-item" v-for="a in leftList" :key="a.id" @tap="onActivityTap(a)">
<view class="thumb-box">
<image class="thumb" :src="a.image" mode="widthFix" />
<view class="tag-hot">HOT</view>
</view>
<view class="info">
<view class="name">{{ a.title }}</view>
<view class="bottom-row">
<text class="price-text">{{ a.category_name }} · {{ a.subtitle }}</text>
<view class="btn-go">GO</view>
</view>
</view>
</view>
<view class="info">
<view class="name">{{ a.title }}</view>
<view class="bottom-row">
<text class="price-text">{{ a.category_name }} · {{ a.subtitle }}</text>
<view class="btn-go">GO</view>
</view>
<!-- 右列 -->
<view class="waterfall-col">
<view class="activity-item" v-for="a in rightList" :key="a.id" @tap="onActivityTap(a)">
<view class="thumb-box">
<image class="thumb" :src="a.image" mode="widthFix" />
<view class="tag-hot">HOT</view>
</view>
<view class="info">
<view class="name">{{ a.title }}</view>
<view class="bottom-row">
<text class="price-text">{{ a.category_name }} · {{ a.subtitle }}</text>
<view class="btn-go">GO</view>
</view>
</view>
</view>
</view>
@ -44,6 +63,9 @@ const filteredActivities = computed(() => {
})
})
const leftList = computed(() => filteredActivities.value.filter((_, i) => i % 2 === 0))
const rightList = computed(() => filteredActivities.value.filter((_, i) => i % 2 !== 0))
function apiGet(url) {
const token = uni.getStorageSync('token')
const fn = token ? authRequest : request
@ -94,10 +116,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 +144,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'
}
@ -150,11 +182,19 @@ onShareTimeline(() => {
flex: 1;
padding: $spacing-lg;
}
.activity-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
/* 瀑布流容器 */
.waterfall-box {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.waterfall-col {
width: 48%; /* 给间距留出空间 */
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.activity-item {
background: #fff;
border-radius: $radius-lg;
@ -174,20 +214,14 @@ onShareTimeline(() => {
.thumb-box {
position: relative;
width: 100%;
padding-top: 100%; /* 1:1 Aspect Ratio */
height: 0;
background: $bg-secondary;
/* 移除固定宽高比,让 mode="widthFix" 撑开高度 */
font-size: 0; /* 消除图片底部间隙 */
}
.thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
background: linear-gradient(90deg, $bg-secondary 25%, #e8e8e8 50%, $bg-secondary 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
/* height 由 widthFix 自动决定 */
background: #f0f0f0;
display: block;
}
.tag-hot {
position: absolute;
@ -207,25 +241,20 @@ onShareTimeline(() => {
padding: 20rpx 20rpx;
display: flex;
flex-direction: column;
flex: 1;
justify-content: space-between;
gap: 12rpx;
}
.name {
font-size: 28rpx;
font-weight: 700;
color: $text-main;
margin-bottom: 16rpx;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
height: 80rpx;
/* 移除固定高度限制,适应不同文字量 */
}
.bottom-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: 4rpx;
}
.price-text {
font-size: 22rpx;
@ -280,15 +309,7 @@ onShareTimeline(() => {
font-size: 28rpx;
}
/* ============================================
动画增强
============================================ */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* 卡片交错入场 */
/* 动画增强 */
@for $i from 1 through 10 {
.activity-item:nth-child(#{$i}) {
animation: fadeInUp 0.4s ease-out #{$i * 0.05}s both;

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,423 @@
<template>
<view class="welfare-detail-page" v-if="detail">
<image v-if="detail.cover_image" class="detail-cover" :src="detail.cover_image" mode="aspectFill" />
<view v-else class="detail-cover empty">暂无活动图片</view>
<view class="detail-panel hero-panel">
<view class="hero-head">
<text class="detail-title">{{ detail.title }}</text>
<text class="detail-type" :class="typeClass(detail.type)">{{ typeLabel(detail.type) }}</text>
</view>
<text class="detail-status">{{ statusText(detail.status) }}</text>
<text class="detail-time">开奖时间{{ formatTime(detail.draw_time) }}</text>
<text class="detail-desc" v-if="detail.description">{{ detail.description }}</text>
</view>
<view class="detail-panel join-panel">
<view class="panel-head">
<text class="panel-title">我的参与进度</text>
<text class="panel-side">{{ progressText }}</text>
</view>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
</view>
<view class="progress-foot">
<text class="progress-hint">{{ progressHint }}</text>
<view
class="join-btn"
:class="joinButtonClass"
@tap="handleJoin"
>
{{ joinButtonText }}
</view>
</view>
</view>
<view class="detail-panel">
<view class="panel-head">
<text class="panel-title">奖品</text>
<text v-if="sortedPrizes.length > 1" class="panel-side">按价格从高到低</text>
</view>
<scroll-view v-if="sortedPrizes.length" scroll-x class="prize-scroll" show-scrollbar="false">
<view class="prize-track">
<view v-for="prize in sortedPrizes" :key="prize.id" class="prize-item">
<image v-if="prize.image" class="prize-image" :src="prize.image" mode="aspectFill" />
<view v-else class="prize-image empty">{{ prizePlaceholderText(prize) }}</view>
<text class="prize-name">{{ prize.name }}</text>
<text class="prize-price">参考价 ¥{{ formatAmount(prizePrice(prize)) }}</text>
<text class="prize-count">x{{ prize.quantity }}</text>
</view>
</view>
</scroll-view>
<view v-else class="empty-text">暂无奖品</view>
</view>
<view class="detail-panel">
<view class="panel-head participant-head">
<text class="panel-title">参与玩家</text>
<view class="participant-head-actions">
<view class="head-action-btn overview" @tap="openWinnerOverview">中奖概览</view>
<view v-if="showMoreParticipants" class="head-action-btn" @tap="openParticipantsPopup">查看更多</view>
</view>
</view>
<view class="participant-meta-row">
<text class="panel-side"> {{ participantCount }} </text>
</view>
<view class="avatar-row" v-if="previewParticipants.length">
<view
v-for="player in previewParticipants"
:key="player.user_id"
class="avatar-item"
:class="{ mine: isSelfParticipant(player) }"
>
<image class="participant-avatar" :src="player.avatar || '/static/logo.png'" mode="aspectFill" />
</view>
</view>
<view v-else class="empty-text">暂无参与玩家</view>
</view>
<view class="detail-panel history-entry" @tap="goHistory">
<view>
<text class="panel-title">查看往期活动</text>
<text class="history-subtitle">浏览已结束的福利活动与中奖信息</text>
</view>
</view>
<view v-if="winnerPopupVisible" class="winner-popup-overlay" @touchmove.stop.prevent>
<view class="winner-popup-mask" @tap="winnerPopupVisible = false"></view>
<view class="winner-popup-panel" @tap.stop>
<view class="winner-popup-head">
<text class="winner-popup-title">当前活动中奖概览</text>
<text class="winner-popup-close" @tap="winnerPopupVisible = false">×</text>
</view>
<scroll-view scroll-y class="winner-popup-list">
<view v-if="winnerOverviewList.length">
<view v-for="item in winnerOverviewList" :key="item.id" class="winner-popup-item">
<image v-if="item.prize_image" class="winner-popup-image" :src="item.prize_image" mode="aspectFill" />
<view v-else class="winner-popup-image empty">{{ rewardPlaceholderText(item) }}</view>
<view class="winner-popup-info">
<text class="winner-popup-name">{{ item.nickname || ('用户' + item.user_id) }} · {{ item.prize_name }}</text>
<text class="winner-popup-price">参考价 ¥{{ formatAmount(winnerPrice(item)) }}</text>
<text class="winner-popup-time">{{ formatTime(item.created_at) }}</text>
</view>
</view>
</view>
<view v-else class="empty-text">还未开奖</view>
</scroll-view>
</view>
</view>
<view v-if="participantsPopupVisible" class="winner-popup-overlay" @touchmove.stop.prevent>
<view class="winner-popup-mask" @tap="participantsPopupVisible = false"></view>
<view class="winner-popup-panel" @tap.stop>
<view class="winner-popup-head">
<text class="winner-popup-title">全部参与玩家</text>
<text class="winner-popup-close" @tap="participantsPopupVisible = false">×</text>
</view>
<scroll-view scroll-y class="winner-popup-list">
<view v-if="allParticipants.length">
<view v-for="player in allParticipants" :key="player.user_id" class="winner-popup-item participant-popup-item">
<image class="winner-popup-image participant-popup-avatar" :src="player.avatar || '/static/logo.png'" mode="aspectFill" />
<view class="winner-popup-info">
<text class="winner-popup-name">{{ player.nickname || ('用户' + player.user_id) }}</text>
<text class="winner-popup-time" :class="{ 'self-tag': isSelfParticipant(player) }">
{{ isSelfParticipant(player) ? '我已参与' : '活动参与玩家' }}
</text>
</view>
</view>
</view>
<view v-else class="empty-text">暂无参与玩家</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
import { request, authRequest } from '@/utils/request.js'
export default {
data() {
return {
detail: null,
winners: [],
participants: [],
participantsPage: 1,
participantsPageSize: 20,
participantsLoading: false,
winnerPopupVisible: false,
participantsPopupVisible: false,
currentUserId: 0
}
},
computed: {
sortedPrizes() {
const prizes = Array.isArray(this.detail?.prizes) ? [...this.detail.prizes] : []
return prizes.sort((a, b) => {
const aPrice = this.prizePrice(a)
const bPrice = this.prizePrice(b)
if (aPrice !== bPrice) return bPrice - aPrice
return Number(a.sort || 0) - Number(b.sort || 0)
})
},
progressPercent() {
const current = Number(this.detail?.current_paid || 0)
const target = Number(this.detail?.threshold_amount || 0)
if (target <= 0) return this.detail?.joined ? 100 : 0
return Math.max(0, Math.min(100, Math.round((current / target) * 100)))
},
progressText() {
const current = this.formatAmount(this.detail?.current_paid || 0)
const target = this.formatAmount(this.detail?.threshold_amount || 0)
return `${this.windowLabel(this.detail?.type)}消费 ${current}/${target}`
},
progressHint() {
if (this.detail?.joined) return '您已成功参加本期活动'
if (this.detail?.can_join) return '已达参与门槛,可立即参加活动'
const startTime = this.detail?.start_time ? new Date(this.detail.start_time).getTime() : 0
if (startTime && startTime > Date.now()) {
return `活动将于 ${this.formatTime(this.detail.start_time)} 开始`
}
const target = Number(this.detail?.threshold_amount || 0)
const current = Number(this.detail?.current_paid || 0)
if (target > current) {
return `还差 ¥${this.formatAmount(target - current)} 即可参加`
}
return '当前未满足参加条件'
},
joinButtonText() {
if (this.detail?.joined) return '已参加'
const startTime = this.detail?.start_time ? new Date(this.detail.start_time).getTime() : 0
if (startTime && startTime > Date.now()) return '未开始'
if (this.detail?.can_join) return '参加活动'
return '未达门槛'
},
joinButtonClass() {
if (this.detail?.joined) return 'disabled'
if (this.detail?.can_join) return 'primary'
return 'muted'
},
participantCount() {
return Number(this.detail?.participant_count || this.participants.length || 0)
},
previewParticipants() {
return this.sortedParticipants.slice(0, 6)
},
sortedParticipants() {
const list = Array.isArray(this.participants) ? [...this.participants] : []
if (!this.currentUserId) return list
return list.sort((a, b) => {
const aSelf = Number(a?.user_id || 0) === this.currentUserId ? 1 : 0
const bSelf = Number(b?.user_id || 0) === this.currentUserId ? 1 : 0
return bSelf - aSelf
})
},
showMoreParticipants() {
return this.participantCount > this.previewParticipants.length
},
allParticipants() {
return this.sortedParticipants
},
winnerOverviewList() {
return Array.isArray(this.winners) ? this.winners : []
},
},
onLoad(options) {
this.id = Number(options?.id || 0)
this.loadData()
},
methods: {
async loadData() {
if (!this.id) return
try {
try {
this.detail = await authRequest({ url: `/api/app/welfare-activities/${this.id}/my`, suppressAuthModal: true })
} catch (_) {
this.detail = await request({ url: `/api/app/welfare-activities/${this.id}` })
}
if (!this.detail.cover_image && this.detail.prizes && this.detail.prizes.length) {
this.detail.cover_image = this.detail.prizes.find((item) => item.image)?.image || ''
}
this.participants = Array.isArray(this.detail?.participants) ? this.detail.participants : []
this.currentUserId = Number(uni.getStorageSync('user_id') || 0)
this.participantsPage = 1
const winnersRes = await request({ url: `/api/app/welfare-activities/${this.id}/winners?page=1&page_size=100` })
this.winners = winnersRes?.list || []
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
},
async handleJoin() {
if (this.detail?.joined || !this.detail?.can_join) return
try {
await authRequest({ url: `/api/app/welfare-activities/${this.id}/join`, method: 'POST' })
uni.showToast({ title: '参加成功', icon: 'success' })
await this.loadData()
} catch (e) {
uni.showToast({ title: e.message || '参加失败', icon: 'none' })
}
},
async loadMoreParticipants() {
if (this.participantsLoading) return
if (this.participants.length >= this.participantCount) return
this.participantsLoading = true
try {
const nextPage = this.participantsPage + 1
const res = await request({
url: `/api/app/welfare-activities/${this.id}/participants?page=${nextPage}&page_size=${this.participantsPageSize}`
})
const list = Array.isArray(res?.list) ? res.list : []
const seen = new Set(this.participants.map(item => String(item.user_id)))
list.forEach(item => {
const key = String(item.user_id)
if (!seen.has(key)) {
seen.add(key)
this.participants.push(item)
}
})
this.participantsPage = nextPage
} catch (e) {
uni.showToast({ title: e.message || '加载参与玩家失败', icon: 'none' })
} finally {
this.participantsLoading = false
}
},
async openParticipantsPopup() {
this.participantsPopupVisible = true
await this.loadAllParticipants()
},
async loadAllParticipants() {
if (this.participantsLoading) return
while (this.participants.length < this.participantCount) {
const prevLength = this.participants.length
await this.loadMoreParticipants()
if (this.participants.length === prevLength) break
}
},
openWinnerOverview() {
this.winnerPopupVisible = true
},
goHistory() {
uni.navigateTo({ url: '/pages-activity/activity/welfare/index?mode=finished' })
},
statusText(status) {
return { active: '进行中', finished: '已结束' }[status] || status || '-'
},
typeLabel(type) {
return { daily: '每日福利', weekly: '每周福利', monthly: '每月福利' }[type] || '福利活动'
},
typeClass(type) {
return {
daily: 'type-daily',
weekly: 'type-weekly',
monthly: 'type-monthly'
}[type] || 'type-default'
},
windowLabel(type) {
return { daily: '每日', weekly: '每周', monthly: '每月' }[type] || '活动'
},
formatTime(v) {
if (!v) return '-'
return String(v).replace('T', ' ').slice(0, 16)
},
formatAmount(cents) {
return (Number(cents || 0) / 100).toFixed(2)
},
prizePrice(prize) {
return Number(prize?.price_cents ?? prize?.price ?? prize?.product_price ?? prize?.price_snapshot_cents ?? 0)
},
winnerPrice(item) {
return Number(item?.price_cents ?? item?.price ?? item?.product_price ?? item?.price_snapshot_cents ?? 0)
},
rewardPlaceholderText(item) {
const type = String(item?.reward_type || '').toLowerCase()
if (type === 'coupon') return '优惠券'
if (type === 'item_card') return '道具卡'
return '奖品'
},
prizePlaceholderText(prize) {
return this.rewardPlaceholderText(prize)
},
isSelfParticipant(player) {
return Number(player?.user_id || 0) > 0 && Number(player?.user_id || 0) === this.currentUserId
}
}
}
</script>
<style lang="scss">
.welfare-detail-page { min-height: 100vh; padding-bottom: 40rpx; background: #fff7ed; }
.detail-cover { width: 100%; height: 420rpx; display: block; background: #f3f4f6; }
.detail-cover.empty { display: flex; align-items: center; justify-content: center; color: #9ca3af; }
.detail-panel { margin: 24rpx 28rpx 0; padding: 28rpx; border-radius: 28rpx; background: #fff; box-shadow: 0 12rpx 30rpx rgba(0,0,0,.06); }
.hero-head { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
.detail-title { display: block; font-size: 38rpx; font-weight: 900; color: #1f2937; flex: 1; }
.detail-type { display: inline-flex; align-items: center; padding: 10rpx 20rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 800; }
.type-daily { background: rgba(249, 115, 22, .12); color: #f97316; }
.type-weekly { background: rgba(239, 68, 68, .12); color: #ef4444; }
.type-monthly { background: linear-gradient(135deg, #a855f7, #ec4899, #f59e0b); color: #fff; }
.type-default { background: rgba(148, 163, 184, .12); color: #64748b; }
.detail-status { display: inline-block; margin-top: 16rpx; padding: 8rpx 20rpx; border-radius: 999rpx; background: #fef3c7; color: #b45309; font-size: 22rpx; font-weight: 800; }
.detail-time, .detail-desc { display: block; margin-top: 16rpx; font-size: 24rpx; color: #4b5563; }
.panel-head { margin-bottom: 18rpx; display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.panel-head.compact { margin-bottom: 10rpx; }
.panel-title { font-size: 28rpx; font-weight: 900; color: #1f2937; }
.panel-side { font-size: 22rpx; color: #9ca3af; }
.progress-bar { height: 18rpx; border-radius: 999rpx; background: #fed7aa; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 999rpx; background: linear-gradient(90deg, #f97316, #fb7185); }
.progress-foot { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 20rpx; }
.progress-hint { flex: 1; font-size: 22rpx; color: #6b7280; }
.join-btn { min-width: 180rpx; padding: 18rpx 28rpx; text-align: center; border-radius: 999rpx; font-size: 24rpx; font-weight: 800; }
.join-btn.primary { background: linear-gradient(135deg, #fb923c, #f97316); color: #fff; }
.join-btn.disabled { background: #dcfce7; color: #16a34a; }
.join-btn.muted { background: #f3f4f6; color: #9ca3af; }
.prize-scroll { white-space: nowrap; }
.prize-track { display: inline-flex; gap: 20rpx; }
.prize-item { width: 260rpx; flex-shrink: 0; }
.prize-image { width: 260rpx; height: 180rpx; border-radius: 20rpx; background: #f3f4f6; }
.prize-image.empty { display: flex; align-items: center; justify-content: center; color: #9ca3af; }
.prize-name { display: -webkit-box; margin-top: 12rpx; min-height: 68rpx; font-size: 24rpx; font-weight: 700; color: #1f2937; white-space: normal; word-break: break-all; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.prize-price { display: block; margin-top: 8rpx; font-size: 22rpx; color: #f97316; line-height: 1.4; }
.prize-count { display: block; margin-top: 6rpx; font-size: 22rpx; color: #6b7280; line-height: 1.4; }
.participant-preview { display: flex; align-items: center; gap: 12rpx; overflow: hidden; }
.participant-head { align-items: flex-start; }
.participant-head-actions { display: flex; align-items: center; gap: 12rpx; flex-shrink: 0; }
.participant-meta-row { margin-bottom: 16rpx; }
.avatar-row { display: flex; align-items: center; gap: 12rpx; overflow-x: auto; padding-bottom: 4rpx; }
.avatar-item { position: relative; flex-shrink: 0; }
.avatar-item.mine .participant-avatar { border-color: #fb923c; box-shadow: 0 0 0 4rpx rgba(251, 146, 60, 0.16); }
.participant-avatar { width: 68rpx; height: 68rpx; border-radius: 50%; background: #f3f4f6; border: 4rpx solid #fff; box-shadow: 0 8rpx 18rpx rgba(0,0,0,.08); }
.head-action-btn { flex-shrink: 0; padding: 14rpx 20rpx; border-radius: 999rpx; background: #fff7ed; color: #f97316; font-size: 22rpx; font-weight: 800; border: 2rpx solid rgba(249,115,22,.18); }
.head-action-btn.overview { background: linear-gradient(135deg, #fff7ed, #ffedd5); }
.more-count { min-width: 68rpx; height: 68rpx; padding: 0 16rpx; border-radius: 34rpx; background: #fff7ed; color: #f97316; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 800; }
.participant-list { display: none; }
.participant-overview-card, .participant-card { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 20rpx; border-radius: 22rpx; background: #fff7ed; }
.overview-icon { width: 76rpx; height: 76rpx; border-radius: 50%; background: linear-gradient(135deg, #fde68a, #fb7185); display: flex; align-items: center; justify-content: center; font-size: 34rpx; flex-shrink: 0; }
.participant-main { display: flex; align-items: center; gap: 14rpx; flex: 1; min-width: 0; }
.participant-card-avatar { width: 76rpx; height: 76rpx; border-radius: 50%; background: #f3f4f6; }
.participant-card-info { flex: 1; min-width: 0; }
.participant-name { display: block; font-size: 26rpx; font-weight: 800; color: #1f2937; }
.participant-sub { display: block; margin-top: 8rpx; font-size: 22rpx; color: #6b7280; }
.winner-btn { padding: 14rpx 20rpx; border-radius: 999rpx; background: #fff; color: #f97316; font-size: 22rpx; font-weight: 800; flex-shrink: 0; }
.overview-btn { min-width: 112rpx; text-align: center; }
.expand-btn { display: none; }
.empty-text { font-size: 24rpx; color: #9ca3af; }
.history-entry { display: flex; justify-content: space-between; align-items: center; }
.history-subtitle { display: block; margin-top: 10rpx; font-size: 22rpx; color: #9ca3af; }
.winner-popup-overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; }
.winner-popup-mask { position: absolute; inset: 0; background: rgba(0,0,0,.48); }
.winner-popup-panel { position: relative; width: 88%; max-height: 72vh; background: #fff; border-radius: 28rpx; overflow: hidden; box-shadow: 0 18rpx 50rpx rgba(0,0,0,.18); }
.winner-popup-head { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 26rpx 28rpx; border-bottom: 2rpx solid #f3f4f6; }
.winner-popup-title { flex: 1; font-size: 28rpx; font-weight: 900; color: #1f2937; }
.winner-popup-close { font-size: 42rpx; color: #9ca3af; line-height: 1; }
.winner-popup-list { max-height: 54vh; padding: 24rpx 28rpx; }
.winner-popup-item { display: flex; gap: 16rpx; align-items: center; padding: 18rpx 0; }
.participant-popup-item { align-items: center; }
.participant-popup-avatar { border-radius: 50%; }
.self-tag { color: #f97316; font-weight: 800; }
.winner-popup-image { width: 96rpx; height: 96rpx; border-radius: 20rpx; background: #f3f4f6; flex-shrink: 0; }
.winner-popup-image.empty { display: flex; align-items: center; justify-content: center; color: #9ca3af; }
.winner-popup-info { flex: 1; min-width: 0; }
.winner-popup-name { display: block; font-size: 26rpx; font-weight: 800; color: #1f2937; }
.winner-popup-price, .winner-popup-time { display: block; margin-top: 8rpx; font-size: 22rpx; color: #6b7280; }
</style>

View File

@ -0,0 +1,108 @@
<template>
<view class="welfare-list-page">
<view class="page-head">
<text class="page-title">{{ mode === 'finished' ? '往期活动' : '福利活动' }}</text>
<text class="page-subtitle">{{ mode === 'finished' ? '查看已结束活动' : '当前仅展示进行中的活动' }}</text>
</view>
<view class="toolbar">
<view class="toolbar-btn" :class="{ active: mode === 'active' }" @tap="switchMode('active')">进行中</view>
<view class="toolbar-btn" :class="{ active: mode === 'finished' }" @tap="switchMode('finished')">往期活动</view>
</view>
<view v-if="loading" class="state">加载中...</view>
<view v-else-if="activities.length === 0" class="state">{{ mode === 'finished' ? '暂无往期活动' : '暂无进行中的活动' }}</view>
<view v-else class="activity-grid">
<view v-for="item in activities" :key="item.id" class="activity-card" @tap="goDetail(item.id)">
<image v-if="item.cover_image" class="activity-cover" :src="item.cover_image" mode="aspectFill" />
<view v-else class="activity-cover empty">暂无图片</view>
<view class="activity-meta">
<text class="activity-name">{{ item.title }}</text>
<view class="activity-type-row">
<text class="activity-type" :class="typeClass(item.type)">{{ typeLabel(item.type) }}</text>
<text class="activity-status" :class="mode === 'finished' ? 'status-finished' : 'status-active'">
{{ mode === 'finished' ? '已结束' : '进行中' }}
</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { request } from '@/utils/request.js'
export default {
data() {
return {
loading: false,
mode: 'active',
activities: []
}
},
onLoad(options) {
this.mode = options?.mode === 'finished' ? 'finished' : 'active'
this.loadData()
},
methods: {
async loadData() {
this.loading = true
try {
const status = this.mode === 'finished' ? 'finished' : 'active'
const res = await request({ url: `/api/app/welfare-activities?status=${status}&page=1&page_size=50` })
this.activities = Array.isArray(res?.list) ? res.list : []
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
this.loading = false
}
},
switchMode(mode) {
if (this.mode === mode) return
this.mode = mode
this.loadData()
},
goDetail(id) {
uni.navigateTo({ url: `/pages-activity/activity/welfare/detail?id=${id}` })
},
typeLabel(type) {
return { daily: '每日福利', weekly: '每周福利', monthly: '每月福利' }[type] || '福利活动'
},
typeClass(type) {
return {
daily: 'type-daily',
weekly: 'type-weekly',
monthly: 'type-monthly'
}[type] || 'type-default'
}
}
}
</script>
<style lang="scss">
.welfare-list-page { min-height: 100vh; padding: 28rpx; background: #fff7ed; }
.page-head { margin-bottom: 28rpx; }
.page-title { display: block; font-size: 46rpx; font-weight: 900; color: #1f2937; }
.page-subtitle { display: block; margin-top: 10rpx; font-size: 24rpx; color: #9ca3af; }
.toolbar { display: flex; gap: 16rpx; margin-bottom: 28rpx; }
.toolbar-btn { flex: 1; text-align: center; padding: 20rpx 0; background: #fff; border-radius: 999rpx; color: #9a5b24; font-weight: 800; }
.toolbar-btn.active { background: #ff8a3d; color: #fff; }
.state { padding: 120rpx 0; text-align: center; color: #9ca3af; }
.activity-grid { display: flex; flex-direction: column; gap: 24rpx; }
.activity-card { display: flex; overflow: hidden; border-radius: 28rpx; background: #fff; box-shadow: 0 12rpx 30rpx rgba(0,0,0,.06); min-height: 220rpx; }
.activity-cover { width: 240rpx; height: 220rpx; display: block; background: #f3f4f6; flex-shrink: 0; }
.activity-cover.empty { display: flex; align-items: center; justify-content: center; color: #9ca3af; }
.activity-meta { flex: 1; padding: 24rpx 22rpx; display: flex; flex-direction: column; justify-content: space-between; min-width: 0; }
.activity-name { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 30rpx; font-weight: 800; color: #1f2937; line-height: 1.4; }
.activity-type-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 12rpx; flex-wrap: wrap; }
.activity-type { display: inline-flex; align-items: center; padding: 8rpx 18rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 800; }
.type-daily { background: rgba(249, 115, 22, .12); color: #f97316; }
.type-weekly { background: rgba(239, 68, 68, .12); color: #ef4444; }
.type-monthly { background: linear-gradient(135deg, #a855f7, #ec4899, #f59e0b); color: #fff; }
.type-default { background: rgba(148, 163, 184, .12); color: #64748b; }
.activity-status { font-size: 22rpx; font-weight: 700; }
.status-active { color: #10b981; }
.status-finished { color: #94a3b8; }
</style>

View File

@ -0,0 +1,691 @@
<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 || [])
list = list.filter(i => i.sub_status !== 'expired')
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)
}
if (uni.getStorageSync('token')) {
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,
// 用户信息
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,345 @@
<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('minesweeper')"
>
<text class="enter-btn-text">{{ entering ? '正在进入...' : (ticketCount > 0 ? '立即开局' : '资格不足') }}</text>
</view>
<view
class="btn-secondary"
@tap="goRoomList"
>
<text class="secondary-btn-text">📡 对战列表 / 围观</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(code) {
const targetCode = code || this.gameCode
if (this.ticketCount <= 0) return
if (this.entering) return
this.entering = true
try {
const res = await authRequest({
url: '/api/app/games/enter',
method: 'POST',
data: {
game_code: targetCode
}
})
const nakamaServer = 'wss://game.1024tool.vip'
const gameBaseUrl = 'https://game.1024tool.vip'
const userInfo = uni.getStorageSync('user_info') || {}
const uid = userInfo.id || userInfo.user_id || ''
const nickname = encodeURIComponent(userInfo.nickname || userInfo.name || '')
const gameUrl = `${gameBaseUrl}/?game_token=${encodeURIComponent(res.game_token)}&nakama_server=${encodeURIComponent(nakamaServer)}&nakama_key=${encodeURIComponent(res.nakama_key)}&game_type=${encodeURIComponent(targetCode)}&uid=${encodeURIComponent(uid)}&nickname=${nickname}`
uni.navigateTo({
url: `/pages-game/game/webview?url=${encodeURIComponent(gameUrl)}`
})
} catch (e) {
uni.showToast({
title: e.message || '进入游戏失败',
icon: 'none'
})
} finally {
this.entering = false
this.loadTickets()
}
},
async goRoomList() {
try {
const res = await authRequest({
url: '/api/app/games/enter',
method: 'POST',
data: { game_code: this.gameCode }
})
const nakamaServer = 'wss://kdy.1024tool.vip'
const gameBaseUrl = 'http://192.168.31.185:8082'
const userInfo = uni.getStorageSync('user_info') || {}
const uid = userInfo.id || userInfo.user_id || ''
const gameUrl = `${gameBaseUrl}/?game_token=${encodeURIComponent(res.game_token)}&nakama_server=${encodeURIComponent(nakamaServer)}&nakama_key=${encodeURIComponent(res.nakama_key)}&game_type=${encodeURIComponent(this.gameCode)}&uid=${encodeURIComponent(uid)}&scene=room-list`
uni.navigateTo({
url: `/pages-game/game/webview?url=${encodeURIComponent(gameUrl)}`
})
} 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;
opacity: 0.6;
pointer-events: none;
}
}
.btn-secondary {
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;
}
.secondary-btn-text {
color: #94a3b8;
font-size: 32rpx;
font-weight: 600;
}
.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>

View File

@ -0,0 +1,399 @@
<template>
<view class="page">
<view class="bg-decoration"></view>
<!-- 对战分说明 -->
<view class="score-tip glass-card">
<text class="score-tip-icon">💡</text>
<text class="score-tip-text">对战分 = 游戏内排名积分赢局 +1000 基础分得分/伤害/宝箱均有加成与平台充值积分无关</text>
</view>
<!-- 我的排名 -->
<view class="my-card glass-card" v-if="myRank">
<view class="my-left">
<text class="my-label">我的排名</text>
<text class="my-rank">{{ myRank.rank ? `#${myRank.rank}` : '未上榜' }}</text>
</view>
<view class="my-divider"></view>
<view class="my-stats">
<view class="stat-col">
<text class="stat-val">{{ myRank.wins || 0 }}</text>
<text class="stat-key">胜场</text>
</view>
<view class="stat-col">
<text class="stat-val">{{ myRank.matches_played || 0 }}</text>
<text class="stat-key">总场次</text>
</view>
<view class="stat-col">
<text class="stat-val">{{ formatWinRate(myRank.win_rate) }}</text>
<text class="stat-key">胜率</text>
</view>
</view>
<view class="my-divider"></view>
<view class="my-right">
<text class="my-pts">{{ myRank.total_rank_points || 0 }}</text>
<text class="my-pts-label">对战分</text>
</view>
</view>
<!-- 榜单 -->
<scroll-view scroll-y class="list-wrap" @scrolltolower="loadMore">
<view v-if="loading && list.length === 0" class="state-box">
<text class="state-icon"></text>
<text class="state-text">加载中...</text>
</view>
<view v-else-if="!loading && list.length === 0" class="state-box">
<text class="state-icon">🏆</text>
<text class="state-text">暂无数据快来挑战吧</text>
</view>
<view v-else class="list">
<view
v-for="item in list"
:key="item.user_id"
class="rank-row glass-card"
:class="{ 'is-me': item.user_id === myUserId }"
>
<view class="rank-badge">
<text v-if="item.rank === 1" class="medal">🥇</text>
<text v-else-if="item.rank === 2" class="medal">🥈</text>
<text v-else-if="item.rank === 3" class="medal">🥉</text>
<text v-else class="rank-no">{{ item.rank }}</text>
</view>
<image class="avatar" :src="item.avatar || fallback" mode="aspectFill" />
<view class="item-info">
<view class="name-row">
<text class="item-name">{{ item.nickname || '匿名玩家' }}</text>
<text v-if="item.user_id === myUserId" class="me-tag"></text>
</view>
<text class="item-sub">{{ item.wins }} · {{ item.matches_played }} · {{ formatWinRate(item.win_rate) }}胜率</text>
</view>
<view class="pts-box">
<text class="pts-val">{{ item.total_rank_points }}</text>
<text class="pts-unit"></text>
</view>
</view>
<view v-if="loadingMore" class="footer-tip"><text class="footer-txt">加载中...</text></view>
<view v-if="!hasMore && list.length > 0" class="footer-tip"><text class="footer-txt"> 已显示全部 </text></view>
</view>
</scroll-view>
</view>
</template>
<script>
import { authRequest } from '../../../utils/request.js'
export default {
data() {
return {
gameType: 'minesweeper',
list: [],
myRank: null,
myUserId: null,
total: 0,
page: 1,
pageSize: 20,
loading: false,
loadingMore: false,
hasMore: true,
fallback: 'https://via.placeholder.com/80/FF6B00/FFFFFF?text=U',
}
},
onLoad() {
const info = uni.getStorageSync('user_info') || {}
this.myUserId = info.id || info.user_id || null
this.fetchList(true)
},
methods: {
async fetchList(reset = false) {
if (reset) {
this.list = []
this.page = 1
this.hasMore = true
this.myRank = null
this.loading = true
} else {
if (!this.hasMore || this.loadingMore) return
this.loadingMore = true
}
try {
const res = await authRequest({
url: '/api/app/games/leaderboard',
method: 'GET',
data: { game_type: this.gameType, page: this.page, page_size: this.pageSize },
})
const items = res.list || []
this.list = reset ? items : [...this.list, ...items]
this.total = res.total || 0
this.myRank = res.me || null
this.hasMore = this.list.length < this.total
if (this.hasMore) this.page++
} catch {
uni.showToast({ title: '加载失败,请重试', icon: 'none' })
} finally {
this.loading = false
this.loadingMore = false
}
},
loadMore() { this.fetchList(false) },
formatWinRate(rate) {
if (rate == null) return '0%'
return `${(rate * 100).toFixed(1)}%`
},
},
}
</script>
<style lang="scss" scoped>
@import '@/uni.scss';
.page {
min-height: 100vh;
background: $bg-page;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* 对战分说明 */
.score-tip {
position: relative;
z-index: 5;
margin: 0 32rpx 20rpx;
padding: 20rpx 24rpx;
display: flex;
align-items: flex-start;
gap: 12rpx;
}
.score-tip-icon {
font-size: 28rpx;
flex-shrink: 0;
line-height: 1.6;
}
.score-tip-text {
font-size: 22rpx;
color: $text-sub;
line-height: 1.6;
}
/* 我的排名 */
.my-card {
position: relative;
z-index: 5;
margin: 0 32rpx 24rpx;
padding: 28rpx 24rpx;
display: flex;
align-items: center;
gap: 0;
}
.my-left {
display: flex;
flex-direction: column;
align-items: center;
min-width: 100rpx;
}
.my-label {
font-size: $font-xs;
color: $text-sub;
margin-bottom: 8rpx;
}
.my-rank {
font-size: 38rpx;
font-weight: 900;
color: $brand-primary;
}
.my-divider {
width: 1px;
height: 60rpx;
background: $border-color-light;
margin: 0 20rpx;
}
.my-stats {
flex: 1;
display: flex;
align-items: center;
justify-content: space-around;
}
.stat-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.stat-val {
font-size: 30rpx;
font-weight: 800;
color: $text-main;
}
.stat-key {
font-size: $font-xs;
color: $text-sub;
}
.my-right {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80rpx;
}
.my-pts {
font-size: 38rpx;
font-weight: 900;
color: $brand-primary;
line-height: 1;
}
.my-pts-label {
font-size: $font-xs;
color: $text-sub;
margin-top: 6rpx;
}
/* 列表 */
.list-wrap {
flex: 1;
height: 0;
padding: 0 32rpx;
}
.state-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
gap: 20rpx;
}
.state-icon { font-size: 80rpx; }
.state-text { font-size: $font-md; color: $text-sub; }
.list {
display: flex;
flex-direction: column;
gap: 16rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.rank-row {
display: flex;
align-items: center;
padding: 20rpx 24rpx;
gap: 20rpx;
&.is-me {
border: 2rpx solid rgba($brand-primary, 0.4);
background: linear-gradient(135deg, rgba($brand-primary, 0.06) 0%, $bg-glass 100%);
}
}
.rank-badge {
width: 52rpx;
height: 52rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.medal { font-size: 44rpx; }
.rank-no {
font-size: $font-md;
font-weight: 800;
color: $text-sub;
}
.avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: $bg-secondary;
flex-shrink: 0;
border: 2rpx solid $border-color-light;
}
.item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.name-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.item-name {
font-size: $font-lg;
font-weight: 700;
color: $text-main;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220rpx;
}
.me-tag {
font-size: $font-xs;
font-weight: 700;
color: #fff;
background: $gradient-brand;
padding: 2rpx 14rpx;
border-radius: $radius-round;
flex-shrink: 0;
}
.item-sub {
font-size: $font-sm;
color: $text-sub;
}
.pts-box {
display: flex;
align-items: baseline;
gap: 4rpx;
flex-shrink: 0;
}
.pts-val {
font-size: 36rpx;
font-weight: 900;
color: $brand-primary;
}
.pts-unit {
font-size: $font-sm;
color: $text-sub;
}
.footer-tip {
padding: 32rpx 0;
text-align: center;
}
.footer-txt {
font-size: $font-sm;
color: $text-tertiary;
}
</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,390 @@
<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) {
// : ,使
const nakamaServer = (this.nakamaServer && this.nakamaServer.includes('yourdomain.com')) || !this.nakamaServer
? 'wss://kdy.1024tool.vip'
: this.nakamaServer;
const nakamaKey = this.nakamaKey || 'defaultkey';
// 使,
if (this.nakamaServer && this.nakamaServer.includes('yourdomain.com')) {
console.warn('[Nakama] 检测到占位符服务器地址,已自动切换为默认服务器: kdy.1024tool.vip');
}
nakamaManager.initClient(nakamaServer, nakamaKey);
//
await nakamaManager.authenticateWithGameToken(this.gameToken);
}
await this.loadRooms();
} catch (e) {
console.error('[房间列表初始化错误]', e);
uni.showToast({
title: '连接失败: ' + (e.message || '未知错误'),
icon: 'none',
duration: 3000
});
} finally {
this.loading = false;
}
},
async loadRooms() {
this.isRefreshing = true;
try {
//
if (!nakamaManager.isConnected) {
await nakamaManager.connect();
}
const res = await nakamaManager.rpc('list_matches', {});
this.rooms = res || [];
//
this.countdown = 5;
} catch (e) {
console.error('Failed to load rooms', e);
//
let errorMsg = '加载失败';
if (e.message) {
if (e.message.includes('socket') || e.message.includes('APIScope')) {
errorMsg = '网络连接异常';
} else if (e.message.includes('timeout')) {
errorMsg = '连接超时';
} else {
errorMsg = e.message;
}
}
//
if (!this.refreshInterval || this.loading) {
uni.showToast({
title: errorMsg,
icon: 'none'
});
}
} 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>

34
pages-game/game/webview.vue Executable file
View File

@ -0,0 +1,34 @@
<template>
<web-view v-if="url" :src="url" @message="onMessage"></web-view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const url = ref('')
onLoad((options) => {
if (options.url) {
url.value = decodeURIComponent(options.url)
console.log('Opening Game WebView:', url.value)
} else {
uni.showToast({ title: '游戏地址无效', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
})
function onMessage(e) {
const data = e.detail.data || []
data.forEach(msg => {
if (msg.action === 'close') {
uni.navigateBack()
} else if (msg.action === 'playAgain') {
uni.navigateBack({
delta: 1,
success: () => uni.$emit('refreshGame')
})
}
})
}
</script>

459
pages-shop/shop/detail.vue Executable file
View File

@ -0,0 +1,459 @@
<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">
<!-- 商品图片轮播 -->
<swiper v-if="imageList.length > 0" :key="'detail-' + swiperKey" class="main-image-swiper" :circular="swiperAutoplay" :autoplay="swiperAutoplay" interval="3000" duration="500">
<swiper-item v-for="(img, index) in imageList" :key="index">
<image class="main-image" :src="img" mode="aspectFill" @tap="previewImage(index)" />
</swiper-item>
</swiper>
<!-- 单张图片显示 -->
<image v-else-if="mainImage" class="main-image" :src="mainImage" mode="aspectFill" @tap="previewImage(0)" />
<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, computed } from 'vue'
import { onLoad, onShow, onHide, onUnload } 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)
const swiperAutoplay = ref(true)
const swiperKey = ref(0)
//
const imageList = computed(() => {
if (!detail.value) return []
// 使 album
if (detail.value.album) {
// album
if (Array.isArray(detail.value.album)) {
return detail.value.album.filter(img => img) //
}
// album
if (typeof detail.value.album === 'string') {
return [detail.value.album]
}
}
// 使 main_image
if (detail.value.main_image) {
return [detail.value.main_image]
}
return []
})
//
const mainImage = computed(() => {
if (imageList.value.length > 0) {
return imageList.value[0]
}
return detail.value.main_image || ''
})
function formatPrice(p) {
if (p === undefined || p === null) return '0.00'
return (Number(p) / 100).toFixed(2)
}
//
function previewImage(index) {
if (imageList.value.length > 0) {
uni.previewImage({
urls: imageList.value,
current: index
})
}
}
// -
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)
// - in_stock stock
if (detail.value.in_stock !== undefined) {
detail.value.stock = detail.value.in_stock ? 99 : 0
}
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: '去登录',
cancelText: '暂不登录',
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)
})
onShow(() => {
swiperKey.value++
swiperAutoplay.value = true
})
onHide(() => {
swiperAutoplay.value = false
})
onUnload(() => {
swiperAutoplay.value = false
})
</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-swiper {
width: 100%;
height: 750rpx;
background: $bg-secondary;
}
.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>

78
pages/address/edit.vue → pages-user/address/edit.vue Normal file → Executable file
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%;

472
pages-user/address/index.vue Executable file
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>

421
pages-user/address/submit.vue Executable file
View File

@ -0,0 +1,421 @@
<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, onShow } 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) {
uni.showModal({
title: '需要登录',
content: '请先登录后再填写收货地址,以便将奖品转移至您的账户',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
const currentPage = `/pages-user/address/submit?token=${token.value}`
uni.navigateTo({ url: `/pages/login/index?redirect=${encodeURIComponent(currentPage)}` })
} else {
uni.navigateBack()
}
}
})
return
}
loadAddressList()
} else {
uni.showToast({ title: '参数错误', icon: 'none' })
}
})
//
onShow(() => {
const newLoginState = !!uni.getStorageSync('token')
if (newLoginState && !isLoggedIn.value) {
isLoggedIn.value = true
loadAddressList()
}
})
//
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 (!isLoggedIn.value) {
uni.showToast({ title: '请先登录后再提交', icon: 'none' })
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

@ -19,6 +19,8 @@
<view class="ol">
<view class="li">发货时效发货订单提交成功后本平台将在3-15个工作日内安排发货预售商品按页面说明执行</view>
<view class="li">物流信息您可在我的订单中查看物流状态因地址错误联系不畅导致的配送失败责任由您承担</view>
<view class="li">用户收到货物后需要保留完整的开箱视频以便售后作为依据无视频依据将不与售后处理</view>
<view class="li">在对产品质量等问题发生时我们仅提供退换服务如有更多诉求我们将协助用户与供货商进行沟通处理</view>
</view>
<view class="h2">售后服务</view>
<view class="ol">

View File

794
pages-user/coupons/index.vue Executable file
View File

@ -0,0 +1,794 @@
<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>
<!-- 内容区 -->
<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(item)"
: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" :class="getLabelClass(item)">{{ item.status_desc || '可用' }}</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.used_amount > 0 || (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.used_amount || (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="getStatusTagClass(item)">{{ item.status_desc || '已失效' }}</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 { 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) {
const amount = item.amount || 0
if (!amount) return 0
let used = 0
if (item.used_amount !== undefined) {
used = item.used_amount
} else if (item.remaining !== undefined) {
used = amount - item.remaining
}
if (used <= 0) return 0
// use Math.min to cap at 100% just in case
return Math.min(100, Math.floor((used / amount) * 100))
}
//
function getEmptyText() {
if (currentTab.value === 1) return '暂无可用优惠券'
return '暂无失效优惠券'
}
//
function getCouponClass(item) {
const sub = item?.sub_status || ''
if (currentTab.value === 2) return 'coupon-invalid'
if (sub === 'in_use') return 'coupon-in-use'
return ''
}
//
function getLabelClass(item) {
const sub = item?.sub_status || ''
if (sub === 'in_use') return 'label-in-use'
if (sub === 'unused') return 'label-unused'
return 'label-invalid'
}
// Tab
function getStatusTagClass(item) {
const sub = item?.sub_status || ''
if (sub === 'expired') return 'expired'
return 'used'
}
// 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
// status: 1=, 2=
const res = await getUserCoupons(userId, 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;
margin-top: 8rpx;
padding: 2rpx 8rpx;
border-radius: 6rpx;
}
.label-unused {
color: $brand-primary;
border: 1px solid $brand-primary;
}
.label-in-use {
color: #FF8D3F;
border: 1px solid #FF8D3F;
}
.label-invalid {
color: $text-tertiary;
border: 1px solid $text-tertiary;
}
/* 分割线 */
.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;
display: flex;
align-items: center;
}
.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-invalid .coupon-left {
background: #f9f9f9;
}
.coupon-invalid .coupon-value,
.coupon-invalid .coupon-label {
color: $text-tertiary;
border-color: $text-tertiary;
}
.coupon-invalid .coupon-name {
color: $text-sub;
}
.coupon-in-use .coupon-left {
background: linear-gradient(135deg, #FFF3E0, #fff);
}
/* 加载更多 */
.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>

4
pages/help/index.vue → pages-user/help/index.vue Normal file → Executable file
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>

538
pages-user/invite/landing.vue Executable file
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>

403
pages-user/invites/index.vue Executable file
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>

718
pages-user/item-cards/index.vue Executable file
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: $gradient-brand;
padding: 12rpx 28rpx;
border-radius: 40rpx;
box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.25);
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>

1024
pages-user/orders/detail.vue Executable file

File diff suppressed because it is too large Load Diff

201
pages/orders/index.vue → pages-user/orders/index.vue Normal file → Executable file
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')
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: '请先登录并绑定手机号',
@ -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;

64
pages/points/index.vue → pages-user/points/index.vue Normal file → Executable file
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;

196
pages-user/settings/index.vue Executable file
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>

View File

@ -0,0 +1,699 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<!-- 装饰光球 -->
<view class="orb orb-1"></view>
<view class="orb orb-2"></view>
<view class="header-area">
<view class="page-title">碎片合成</view>
<view class="page-subtitle">Fragment Synthesis</view>
</view>
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="recipes.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="recipe-list">
<view
v-for="(recipe, index) in recipes"
:key="recipe.id"
class="recipe-ticket"
:class="{ 'ticket-ready': recipe.can_synthesize }"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<!-- 左侧目标商品主视觉 -->
<view class="ticket-left">
<view class="product-img-wrap">
<image
v-if="recipe.target_product"
:src="getFirstImage(recipe.target_product.images_json)"
mode="aspectFill"
class="product-img"
/>
<view v-else class="product-img-placeholder">
<text>🎁</text>
</view>
</view>
<!-- 可合成光晕 -->
<view v-if="recipe.can_synthesize" class="ready-glow"></view>
<view class="product-label">
<text class="label-text" :class="recipe.can_synthesize ? 'label-ready' : 'label-lack'">
{{ recipe.can_synthesize ? '可合成' : '待收集' }}
</text>
</view>
</view>
<!-- 分割线带缺口 -->
<view class="ticket-divider">
<view class="notch notch-top"></view>
<view class="divider-line"></view>
<view class="notch notch-bottom"></view>
</view>
<!-- 右侧配方信息 -->
<view class="ticket-right">
<view class="ticket-info">
<text class="product-name">{{ recipe.target_product?.name || '目标商品' }}</text>
<text class="recipe-name">{{ recipe.name }}</text>
<text class="recipe-desc" v-if="recipe.description">{{ recipe.description }}</text>
</view>
<!-- 材料清单 -->
<view class="materials-row">
<view
v-for="(mat, idx) in recipe.materials"
:key="idx"
class="mat-chip"
:class="mat.owned_count >= mat.required_count ? 'mat-ok' : 'mat-lack'"
>
<text class="mat-name">{{ mat.name }}</text>
<text class="mat-num">{{ mat.owned_count }}/{{ mat.required_count }}</text>
</view>
</view>
<!-- 底部进度 + 按钮 -->
<view class="ticket-footer">
<view class="ready-meta">
<text class="ready-hint">
{{ getReadyCount(recipe) }}/{{ recipe.materials?.length || 0 }} 材料就绪
</text>
<text class="batch-hint" v-if="getMaxSynthesizeCount(recipe) > 0">
最多可合成 {{ getMaxSynthesizeCount(recipe) }}
</text>
</view>
<view class="action-group">
<view
class="synth-btn synth-btn-secondary"
:class="recipe.can_synthesize && !batchSynthesizing ? 'btn-ready' : 'btn-locked'"
@tap="onSynthesize(recipe)"
>
<text class="btn-text">{{ synthesizing ? '合成中' : (recipe.can_synthesize ? '单次合成' : '不足') }}</text>
</view>
<view
class="synth-btn synth-btn-primary"
:class="getMaxSynthesizeCount(recipe) > 0 && !synthesizing ? 'btn-ready' : 'btn-locked'"
@tap="onBatchSynthesize(recipe)"
>
<text class="btn-text">{{ batchSynthesizing ? '批量中' : (getMaxSynthesizeCount(recipe) > 0 ? '一键合成' : '不足') }}</text>
<view v-if="getMaxSynthesizeCount(recipe) > 0 && !batchSynthesizing" class="btn-shine"></view>
</view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getSynthesisRecipes, doSynthesis, doBatchSynthesis } from '../../api/synthesis.js'
const loading = ref(true)
const synthesizing = ref(false)
const batchSynthesizing = ref(false)
const isRefreshing = ref(false)
const recipes = ref([])
function getFirstImage(imagesJson) {
if (!imagesJson) return '/static/placeholder.png'
try {
const imgs = JSON.parse(imagesJson)
return imgs && imgs.length > 0 ? imgs[0] : '/static/placeholder.png'
} catch {
return imagesJson
}
}
function getReadyCount(recipe) {
if (!recipe.materials) return 0
return recipe.materials.filter(m => m.owned_count >= m.required_count).length
}
function getOverallProgress(recipe) {
if (!recipe.materials || recipe.materials.length === 0) return 0
return Math.round((getReadyCount(recipe) / recipe.materials.length) * 100)
}
function getMaxSynthesizeCount(recipe) {
return Number(recipe?.max_synthesize_count || 0)
}
function confirmSynthesis({ title, content }) {
return new Promise((resolve, reject) => {
uni.showModal({
title,
content,
success: (res) => res.confirm ? resolve() : reject(new Error('cancel')),
fail: reject
})
})
}
async function loadRecipes() {
loading.value = true
const userId = uni.getStorageSync('user_id')
if (!userId) {
loading.value = false
return
}
try {
const res = await getSynthesisRecipes(userId)
recipes.value = res?.list || []
} catch (e) {
console.error('loadRecipes error', e)
} finally {
loading.value = false
}
}
async function onRefresh() {
isRefreshing.value = true
await loadRecipes()
isRefreshing.value = false
}
async function onSynthesize(recipe) {
if (synthesizing.value || batchSynthesizing.value || !recipe.can_synthesize) return
try {
await confirmSynthesis({
title: '确认合成',
content: `确定要合成「${recipe.target_product?.name || '目标商品'}」吗?合成后碎片将被消耗。`
})
} catch {
return
}
synthesizing.value = true
const userId = uni.getStorageSync('user_id')
try {
await doSynthesis(userId, recipe.id)
uni.showToast({ title: '合成成功!', icon: 'success' })
await loadRecipes()
} catch (e) {
uni.showToast({ title: e?.message || '合成失败', icon: 'none' })
} finally {
synthesizing.value = false
}
}
async function onBatchSynthesize(recipe) {
const maxCount = getMaxSynthesizeCount(recipe)
if (batchSynthesizing.value || synthesizing.value || maxCount <= 0) return
try {
await confirmSynthesis({
title: '确认一键合成',
content: `将消耗当前全部可用碎片,预计合成 ${maxCount} 次「${recipe.target_product?.name || '目标商品'}」,是否继续?`
})
} catch {
return
}
batchSynthesizing.value = true
const userId = uni.getStorageSync('user_id')
try {
const res = await doBatchSynthesis(userId, recipe.id)
const count = Number(res?.synthesized_count || maxCount)
uni.showToast({ title: `一键合成成功,共合成 ${count}`, icon: 'none' })
await loadRecipes()
} catch (e) {
uni.showToast({ title: e?.message || '一键合成失败', icon: 'none' })
} finally {
batchSynthesizing.value = false
}
}
onLoad(() => {
loadRecipes()
})
const onShow = () => {
if (!loading.value) loadRecipes()
}
defineExpose({ onShow })
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
/* 装饰光球 */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80rpx);
opacity: 0.5;
pointer-events: none;
}
.orb-1 {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.25), transparent 70%);
top: -80rpx;
right: -80rpx;
animation: float 10s ease-in-out infinite;
}
.orb-2 {
width: 400rpx;
height: 400rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15), transparent 70%);
bottom: 200rpx;
left: -100rpx;
animation: float 14s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20rpx, 30rpx); }
}
/* Header */
.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;
}
/* 滚动区 */
.content-scroll {
height: calc(100vh - 220rpx);
position: relative;
z-index: 1;
}
/* 加载 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx $spacing-lg;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
margin-bottom: 10rpx;
}
.empty-hint {
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
/* 配方列表 */
.recipe-list {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 0 $spacing-lg $spacing-xl;
}
/* 票券式卡片 */
.recipe-ticket {
display: flex;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: $shadow-card;
animation: fadeInUp 0.5s ease-out backwards;
position: relative;
&.ticket-ready {
box-shadow:
$shadow-card,
0 0 0 1.5rpx rgba($brand-primary, 0.25);
}
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(24rpx); }
to { opacity: 1; transform: translateY(0); }
}
/* 左侧商品区 */
.ticket-left {
width: 200rpx;
flex-shrink: 0;
background: linear-gradient(145deg, #FFF5EC, #FFF0E0);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28rpx 16rpx;
position: relative;
overflow: hidden;
}
.ticket-ready .ticket-left {
background: linear-gradient(145deg, #FFF5EC, #FFE8C8);
}
/* 可合成光晕 */
.ready-glow {
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 40%, rgba($brand-primary, 0.12), transparent 70%);
pointer-events: none;
}
.product-img-wrap {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.08);
margin-bottom: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.product-img {
width: 100%;
height: 100%;
}
.product-img-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
}
.product-label {
padding: 4rpx 16rpx;
border-radius: 100rpx;
background: rgba(255, 255, 255, 0.8);
}
.label-text {
font-size: 20rpx;
font-weight: 700;
&.label-ready {
color: $brand-primary;
}
&.label-lack {
color: $text-tertiary;
}
}
/* 分割线(票券缺口效果) */
.ticket-divider {
width: 28rpx;
position: relative;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.notch {
width: 28rpx;
height: 28rpx;
background: $bg-page;
border-radius: 50%;
position: absolute;
left: 0;
z-index: 2;
&.notch-top { top: -14rpx; }
&.notch-bottom { bottom: -14rpx; }
}
.divider-line {
width: 0;
height: 75%;
border-left: 2rpx dashed rgba(0, 0, 0, 0.1);
}
/* 右侧信息区 */
.ticket-right {
flex: 1;
padding: 24rpx 24rpx 20rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
overflow: hidden;
min-width: 0;
}
.ticket-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.product-name {
font-size: 30rpx;
font-weight: 800;
color: $text-main;
letter-spacing: 0.5rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipe-name {
font-size: 22rpx;
color: $text-sub;
font-weight: 500;
}
.recipe-desc {
font-size: 20rpx;
color: $text-tertiary;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 材料芯片行 */
.materials-row {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.mat-chip {
display: flex;
align-items: center;
gap: 6rpx;
padding: 5rpx 12rpx;
border-radius: 100rpx;
background: rgba(0, 0, 0, 0.04);
border: 1.5rpx solid rgba(0, 0, 0, 0.06);
&.mat-ok {
background: rgba($color-success, 0.08);
border-color: rgba($color-success, 0.2);
}
&.mat-lack {
background: rgba($color-error, 0.06);
border-color: rgba($color-error, 0.15);
}
}
.mat-name {
font-size: 20rpx;
color: $text-sub;
max-width: 100rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mat-num {
font-size: 20rpx;
font-weight: 700;
color: $text-tertiary;
.mat-ok & { color: $color-success; }
.mat-lack & { color: $color-error; }
}
/* 底部行:进度提示 + 按钮 */
.ticket-footer {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14rpx;
margin-top: 4rpx;
}
.ready-meta {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.ready-hint {
font-size: 20rpx;
color: $text-tertiary;
}
.batch-hint {
font-size: 20rpx;
color: $brand-primary;
font-weight: 600;
}
.action-group {
display: flex;
align-items: center;
gap: 12rpx;
}
/* 合成按钮 - 小胶囊 */
.synth-btn {
flex: 1;
min-width: 0;
height: 52rpx;
padding: 0 16rpx;
border-radius: 26rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
&.btn-ready {
background: $gradient-brand;
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.3);
&:active {
transform: scale(0.96);
opacity: 0.9;
}
}
&.btn-locked {
background: rgba(0, 0, 0, 0.05);
}
}
.synth-btn-primary {
min-width: 0;
}
.synth-btn-secondary {
&.btn-ready {
background: linear-gradient(135deg, rgba($brand-primary, 0.14), rgba($brand-primary, 0.08));
box-shadow: none;
border: 1.5rpx solid rgba($brand-primary, 0.25);
}
}
.btn-text {
font-size: 22rpx;
font-weight: 700;
letter-spacing: 0;
position: relative;
z-index: 2;
white-space: nowrap;
}
.btn-shine {
position: absolute;
top: 0;
left: -100%;
width: 60%;
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%, 100% { left: 200%; }
}
/* 加载动画 */
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $brand-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

1362
pages-user/tasks/index.vue Executable file

File diff suppressed because it is too large Load Diff

317
pages.json Normal file → Executable file
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,89 +30,232 @@
"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": "activity/welfare/index",
"style": {
"navigationBarTitleText": "福利活动"
}
},
{
"path": "activity/welfare/detail",
"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": "synthesis/index",
"style": {
"navigationBarTitleText": "碎片合成"
}
}
]
},
{
"path": "pages/points/index",
"style": {
"navigationBarTitleText": "积分记录"
}
"root": "pages-shop",
"pages": [
{
"path": "shop/detail",
"style": {
"navigationBarTitleText": "商品详情"
}
}
]
},
{
"path": "pages/orders/index",
"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/minesweeper/leaderboard",
"style": {
"navigationBarTitleText": "扫雷战绩榜"
}
},
{
"path": "game/webview",
"style": {
"navigationBarTitleText": "游戏挑战",
"navigationBarBackgroundColor": "#000000",
"navigationBarTextStyle": "white"
}
}
]
}
],
"tabBar": {
"custom": false,
"color": "#7A7E83",
"selectedColor": "#007AFF",
"selectedColor": "#FF6B00",
"backgroundColor": "#FFFFFF",
"borderStyle": "black",
"list": [
@ -135,7 +273,7 @@
},
{
"pagePath": "pages/cabinet/index",
"text": "柜",
"text": "柜",
"iconPath": "static/tab/box.png",
"selectedIconPath": "static/tab/box_active.png"
},
@ -153,5 +291,14 @@
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"easycom": {
"autoscan": true,
"custom": {
"^BlessingAnimation": "@/components/BlessingAnimation.vue"
}
},
"mp-weixin": {
"__usePrivacyCheck__": true
},
"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>

1545
pages/cabinet/index.vue Normal file → Executable file

File diff suppressed because it is too large Load Diff

877
pages/index/index.vue Normal file → Executable file

File diff suppressed because it is too large Load Diff

1402
pages/login/index.vue Normal file → Executable file

File diff suppressed because it is too large Load Diff

1624
pages/mine/index.vue Normal file → Executable file

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>

1249
pages/shop/index.vue Normal file → Executable file

File diff suppressed because it is too large Load Diff

0
project.config.json Normal file → Executable file
View File

0
project.private.config.json Normal file → Executable file
View File

BIN
static/logo.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 92 KiB

BIN
static/share_invite.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

0
static/tab/box.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 373 B

After

Width:  |  Height:  |  Size: 373 B

0
static/tab/box_active.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 651 B

After

Width:  |  Height:  |  Size: 651 B

0
static/tab/home.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

0
static/tab/home_active.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

0
static/tab/profile.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Some files were not shown because too many files have changed in this diff Show More