Compare commits
190 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d530ec11e7 | |||
| 8a3676eb9f | |||
| fb520a6895 | |||
| 2895c2d5b7 | |||
|
|
e0a1d6e934 | ||
| 575ccb2cfa | |||
|
|
eca0561cd9 | ||
| 21a174329c | |||
| 49027862b3 | |||
| d0e0b5d4ea | |||
|
|
5c88d91382 | ||
| 63345f4c24 | |||
| d7cd33bcca | |||
|
|
0c794101e7 | ||
| 27a05210ee | |||
|
|
e29864eb4e | ||
| 7487e7224a | |||
|
|
7acbc515aa | ||
| 495b46ec8b | |||
|
|
58ad9e8be3 | ||
| d55be3dbcf | |||
|
|
d643abe7e1 | ||
| eb3257f1bd | |||
|
|
fd252efae1 | ||
| bdd329eb15 | |||
| 3e20dd845a | |||
| bcbe7a9b29 | |||
| be915a1507 | |||
| 499ac1514e | |||
|
|
16076f2eb8 | ||
| 4fe3ecb571 | |||
| b97cd0f267 | |||
| 29f272c22c | |||
| dc2297bbdf | |||
|
|
4d9f7e84e3 | ||
|
|
7fb865b68e | ||
|
|
636041d6fa | ||
| e7256ae88e | |||
|
|
2a98cde85f | ||
|
|
90110f5bce | ||
|
|
ec0a96087c | ||
| 57178b21b3 | |||
| 662a31dac8 | |||
| cdfe233ea8 | |||
|
|
f918bfc81a | ||
| 1cfa7e8322 | |||
| 35932622e0 | |||
|
|
ba89b0f2dc | ||
| f83048f3e9 | |||
| c55fc2954f | |||
|
|
6451394764 | ||
|
|
ef2ebe754f | ||
|
|
33523d2306 | ||
|
|
2390db8186 | ||
| b6ec1958a2 | |||
| 51c6e872f3 | |||
|
|
1af8bc7315 | ||
|
|
108f37e35f | ||
| 5c863de337 | |||
| 83001cfda9 | |||
|
|
1c62867cd2 | ||
| 3b0bf07f77 | |||
| 6da73a1955 | |||
|
|
e05403b673 | ||
|
|
c53e179ce2 | ||
| 01eb9a425a | |||
|
|
184305e6a0 | ||
|
|
8cfe8a2a0c | ||
|
|
77fb15426d | ||
| 8963827c32 | |||
| 5c89355469 | |||
| 5cd4e77d07 | |||
| 470094dc75 | |||
|
|
e903ae2d93 | ||
|
|
9d25477cd3 | ||
|
|
0609f5c531 | ||
|
|
a083681697 | ||
| c1cf14b8fe | |||
| 7edb2e7844 | |||
| b9246bc728 | |||
| c75946676a | |||
|
|
ea7b3e33c0 | ||
|
|
1d2599441e | ||
|
|
96555e690c | ||
|
|
5691d0601d | ||
| 237d785a4f | |||
|
|
bcbb18a939 | ||
| 420912b3a7 | |||
| 41ab104f83 | |||
| 75b6ef7809 | |||
| 413f7557f1 | |||
| 29e3ecbdd4 | |||
| 3e0bc4423a | |||
|
|
874092a0d2 | ||
|
|
3aced9cae5 | ||
| 1b2315b4ea | |||
| d507122f2f | |||
| 1d1c4f29d6 | |||
| 0f7255783a | |||
| 762c248ab1 | |||
|
|
e745d172ff | ||
| 241722e1af | |||
|
|
2c77f124c1 | ||
|
|
676035c5d0 | ||
| 83377543f8 | |||
| 0367a8db8c | |||
|
|
46430edb8b | ||
| 40cfb8c36e | |||
| 45190e1004 | |||
|
|
a304e66e75 | ||
|
|
9309277047 | ||
|
|
3d37bbc8d3 | ||
| c028a29943 | |||
|
|
3a1d4857dd | ||
| 652528a14d | |||
| f69fe30e2b | |||
|
|
8d5cf5ee17 | ||
| 58d9edc766 | |||
|
|
191895567c | ||
| 41bf14eb8f | |||
| b5241d767b | |||
|
|
bea2761453 | ||
|
|
ce1522abf2 | ||
| 625dc1842a | |||
|
|
ac497ce163 | ||
|
|
b959e634d2 | ||
| 5dfb2c3ecb | |||
|
|
66f5c343d8 | ||
| ed67c4f7fa | |||
|
|
5cbd30fcb7 | ||
|
|
152fe14aab | ||
| a8fa8bf557 | |||
|
|
4252a0ed61 | ||
| 7009b47de6 | |||
|
|
05056c8188 | ||
|
|
61df7fca5e | ||
|
|
9c3775624f | ||
|
|
8237e3ef42 | ||
| a63fdd91d3 | |||
| d4d298a275 | |||
|
|
e24f05f6ac | ||
|
|
054b849374 | ||
|
|
ef4e4599f4 | ||
|
|
a4dbfd14b7 | ||
|
|
952a2a2fe7 | ||
|
|
21118ce6f9 | ||
|
|
a634c6caac | ||
|
|
28e0721e3f | ||
| 0bd10c6a0d | |||
|
|
d1fd76e242 | ||
| 73cfd7ef9b | |||
| 3175c6e8ae | |||
| 2af47b7979 | |||
| 75638f895b | |||
| e19ec06d74 | |||
| 3dde150cde | |||
| a3ec9c102d | |||
| b9b60b15a1 | |||
| 4249ad3954 | |||
| 6183fcaf15 | |||
| 7e08aa5f43 | |||
| 7406f8b308 | |||
| d5527625bc | |||
|
|
f0e3cdc407 | ||
| d1f005225a | |||
| 97cfe3f3da | |||
| 148c62a983 | |||
|
|
a18845c849 | ||
|
|
a2cffa84f0 | ||
|
|
449a91e582 | ||
| bfb7d7630f | |||
|
|
f57ecfbaee | ||
|
|
321189a3fe | ||
|
|
5b286d7e8a | ||
|
|
d49a3840a2 | ||
| a350bcc4ed | |||
| be57eda392 | |||
|
|
2d218018e8 | ||
| 0e174f220b | |||
|
|
2571d4a698 | ||
|
|
9f7c98ddad | ||
|
|
ad0232ad21 | ||
|
|
4c3dfdd916 | ||
|
|
f9bc754dec | ||
| 54ce24b7b8 | |||
| d930756130 | |||
| 09ca0c252d | |||
|
|
ffd0073fdd | ||
|
|
f3c0ab6d8f | ||
|
|
de1a80cc13 |
4
.gitignore
vendored
Normal file → Executable file
@ -7,3 +7,7 @@ node_modules/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
.claude/settings.local.json
|
||||
.hbuilderx/project.config.json
|
||||
clean-cache.bat
|
||||
.hbuilderx/launch.json
|
||||
|
||||
@ -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. 自测与联调,完成交付
|
||||
@ -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\_code)(miniapp/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`(balance)(globals.d.ts:773)
|
||||
|
||||
* `GET /api/app/users/{user_id}/stats`(apiDefinitions.js:126)
|
||||
|
||||
* 响应: `App_user_stats_response`(coupon\_count、item\_card\_count、points\_balance)(globals.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、112;miniapp/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` 响应处理一致性。
|
||||
|
||||
@ -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
@ -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
@ -0,0 +1,3 @@
|
||||
{
|
||||
"prompt" : "template"
|
||||
}
|
||||
302
api/appUser.js
Normal file → Executable 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
26
components/FlipGrid.vue
Normal file → Executable 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);
|
||||
|
||||
857
components/GamePassPurchasePopup.vue
Executable 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>
|
||||
|
||||
542
components/GamePassPurchasePopup.vue.backup
Executable 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
@ -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
@ -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
@ -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
@ -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
@ -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 {
|
||||
|
||||
262
components/activity/ActivityHeader.vue
Executable 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>
|
||||
147
components/activity/ActivityPageLayout.vue
Executable 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>
|
||||
118
components/activity/ActivityTabs.vue
Executable 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>
|
||||
228
components/activity/CabinetPreviewPopup.vue
Executable 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>
|
||||
|
||||
319
components/activity/DrawLoadingPopup.vue
Executable 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>
|
||||
502
components/activity/LotteryResultPopup.vue
Executable 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_id作为唯一key,避免同名不同产品被错误合并
|
||||
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>
|
||||
289
components/activity/PrizeClaimPopup.vue
Normal 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>
|
||||
238
components/activity/RecordsList.vue
Executable 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>
|
||||
379
components/activity/RewardsPopup.vue
Executable 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>
|
||||
278
components/activity/RewardsPreview.vue
Executable 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>
|
||||
125
components/activity/RulesPopup.vue
Executable 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
@ -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'
|
||||
87
components/app-tab-bar-toutiao.vue
Executable 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
@ -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
@ -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
@ -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
|
||||
208
docs/代码重构分析/ALIGNMENT_代码冗余分析.md
Executable 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*
|
||||
323
docs/代码重构分析/DESIGN_组件化重构.md
Executable 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
14
main.js
Normal file → Executable 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
@ -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
|
||||
|
||||
2657
pages-activity/activity/duiduipeng/index.vue
Executable file
115
pages/activity/list/index.vue → pages-activity/activity/list/index.vue
Normal file → Executable 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;
|
||||
398
pages-activity/activity/pata/index.vue
Executable 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>
|
||||
423
pages-activity/activity/welfare/detail.vue
Normal 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>
|
||||
108
pages-activity/activity/welfare/index.vue
Normal 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>
|
||||
691
pages-activity/activity/wuxianshang/index.vue
Executable 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 // 最多轮询15次,每次2秒,共30秒
|
||||
|
||||
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>
|
||||
1160
pages-activity/activity/yifanshang/index.vue
Executable file
8
pages-activity/composables/index.js
Executable file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Composables 统一导出
|
||||
*/
|
||||
|
||||
export { useActivity } from './useActivity'
|
||||
export { useIssues } from './useIssues'
|
||||
export { useRewards } from './useRewards'
|
||||
export { useRecords } from './useRecords'
|
||||
72
pages-activity/composables/useActivity.js
Executable 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
|
||||
}
|
||||
}
|
||||
97
pages-activity/composables/useIssues.js
Executable 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
|
||||
}
|
||||
}
|
||||
109
pages-activity/composables/useRecords.js
Executable 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
|
||||
}
|
||||
}
|
||||
86
pages-activity/composables/useRewards.js
Executable 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
|
||||
}
|
||||
}
|
||||
345
pages-game/game/minesweeper/index.vue
Executable 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>
|
||||
399
pages-game/game/minesweeper/leaderboard.vue
Normal 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>
|
||||
1695
pages-game/game/minesweeper/play.scss
Executable file
1240
pages-game/game/minesweeper/play.vue
Executable file
390
pages-game/game/minesweeper/room-list.vue
Executable 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
@ -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
@ -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
@ -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
@ -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
@ -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>
|
||||
2
pages/agreement/purchase.vue → pages-user/agreement/purchase.vue
Normal file → Executable 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">
|
||||
0
pages/agreement/user.vue → pages-user/agreement/user.vue
Normal file → Executable file
794
pages-user/coupons/index.vue
Executable 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
@ -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
@ -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
@ -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
@ -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
201
pages/orders/index.vue → pages-user/orders/index.vue
Normal file → Executable 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
@ -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
@ -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>
|
||||
699
pages-user/synthesis/index.vue
Normal 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
317
pages.json
Normal file → Executable 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": {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
877
pages/index/index.vue
Normal file → Executable file
1402
pages/login/index.vue
Normal file → Executable file
1624
pages/mine/index.vue
Normal file → Executable 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>
|
||||
@ -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
0
project.config.json
Normal file → Executable file
0
project.private.config.json
Normal file → Executable file
BIN
static/logo.png
Normal file → Executable file
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 92 KiB |
BIN
static/share_invite.png
Executable file
|
After Width: | Height: | Size: 44 KiB |
0
static/tab/box.png
Normal file → Executable file
|
Before Width: | Height: | Size: 373 B After Width: | Height: | Size: 373 B |
0
static/tab/box_active.png
Normal file → Executable file
|
Before Width: | Height: | Size: 651 B After Width: | Height: | Size: 651 B |
0
static/tab/home.png
Normal file → Executable 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
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
0
static/tab/profile.png
Normal file → Executable file
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |