Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -7,3 +7,6 @@ node_modules/
|
|||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
|
.claude/settings.local.json
|
||||||
|
.hbuilderx/project.config.json
|
||||||
|
clean-cache.bat
|
||||||
|
|||||||
14
App.vue
14
App.vue
@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { getPublicConfig } from '@/api/appUser'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLaunch: function(options) {
|
onLaunch: function(options) {
|
||||||
console.log('App Launch', options)
|
console.log('App Launch', options)
|
||||||
@ -7,6 +9,18 @@
|
|||||||
console.log('App Launch captured invite_code:', 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) }
|
try { uni.setStorageSync('inviter_code', options.query.invite_code) } catch (e) { console.error('Save invite code failed', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载公开配置 (如订阅消息模板ID)
|
||||||
|
getPublicConfig().then(res => {
|
||||||
|
if (res && res.subscribe_templates) {
|
||||||
|
console.log('Loaded public config:', res)
|
||||||
|
try { uni.setStorageSync('subscribe_templates', res.subscribe_templates) } catch (_) {}
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn('Failed to load public config:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 抖音平台现在也显示首页,不再需要强制跳转
|
||||||
},
|
},
|
||||||
onShow: function() {
|
onShow: function() {
|
||||||
console.log('App Show')
|
console.log('App Show')
|
||||||
|
|||||||
252
api/appUser.js
252
api/appUser.js
@ -5,14 +5,68 @@ export function wechatLogin(code, invite_code) {
|
|||||||
return request({ url: '/api/app/users/weixin/login', method: 'POST', data })
|
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 = {}) {
|
export function bindPhone(user_id, code, extraHeader = {}) {
|
||||||
return authRequest({ url: `/api/app/users/${user_id}/phone/bind`, method: 'POST', data: { code }, header: 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) {
|
export function getUserStats(user_id) {
|
||||||
return authRequest({ url: `/api/app/users/${user_id}/stats`, method: 'GET' })
|
return authRequest({ url: `/api/app/users/${user_id}/stats`, method: 'GET' })
|
||||||
}
|
}
|
||||||
@ -74,12 +128,12 @@ export function getActivityIssueRewards(activity_id, issue_id) {
|
|||||||
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/rewards`, method: 'GET' })
|
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/rewards`, method: 'GET' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function drawActivityIssue(activity_id, issue_id) {
|
export function getIssueDrawLogs(activity_id, issue_id) {
|
||||||
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw`, method: 'POST' })
|
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw_logs`, method: 'GET' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActivityWinRecords(activity_id, page = 1, page_size = 20) {
|
export function drawActivityIssue(activity_id, issue_id) {
|
||||||
return authRequest({ url: `/api/app/activities/${activity_id}/wins`, method: 'GET', data: { page, page_size } })
|
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw`, method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIssueChoices(activity_id, issue_id) {
|
export function getIssueChoices(activity_id, issue_id) {
|
||||||
@ -98,8 +152,20 @@ export function requestShipping(user_id, ids) {
|
|||||||
return authRequest({ url: `/api/app/users/${user_id}/inventory/request-shipping`, method: 'POST', data: { inventory_ids: ids } })
|
return authRequest({ url: `/api/app/users/${user_id}/inventory/request-shipping`, method: 'POST', data: { inventory_ids: ids } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getItemCards(user_id, status) {
|
export function cancelShipping(user_id, batch_no) {
|
||||||
const data = {}
|
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
|
if (status !== undefined) data.status = status
|
||||||
return authRequest({ url: `/api/app/users/${user_id}/item_cards`, method: 'GET', data })
|
return authRequest({ url: `/api/app/users/${user_id}/item_cards`, method: 'GET', data })
|
||||||
}
|
}
|
||||||
@ -130,6 +196,24 @@ export function joinLottery(data) {
|
|||||||
return authRequest({ url: '/api/app/lottery/join', method: 'POST', data })
|
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) {
|
export function createWechatOrder(data) {
|
||||||
return authRequest({ url: '/api/app/pay/wechat/jsapi/preorder', method: 'POST', data })
|
return authRequest({ url: '/api/app/pay/wechat/jsapi/preorder', method: 'POST', data })
|
||||||
}
|
}
|
||||||
@ -146,10 +230,34 @@ export function redeemProductByPoints(user_id, product_id, quantity) {
|
|||||||
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-product`, method: 'POST', data: { product_id, quantity } })
|
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-product`, method: 'POST', data: { product_id, quantity } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function redeemItemCardByPoints(user_id, item_card_id, quantity = 1) {
|
||||||
|
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-item-card`, method: 'POST', data: { item_card_id, quantity } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoreItems(kind = 'product', page = 1, page_size = 20, filters = {}) {
|
||||||
|
const data = { kind, page, page_size }
|
||||||
|
if (filters.keyword) data.keyword = filters.keyword
|
||||||
|
if (filters.price_min !== undefined && filters.price_min !== null && filters.price_min !== '') {
|
||||||
|
data.price_min = parseInt(filters.price_min)
|
||||||
|
}
|
||||||
|
if (filters.price_max !== undefined && filters.price_max !== null && filters.price_max !== '') {
|
||||||
|
data.price_max = parseInt(filters.price_max)
|
||||||
|
}
|
||||||
|
return authRequest({ url: '/api/app/store/items', method: 'GET', data })
|
||||||
|
}
|
||||||
|
|
||||||
export function getTasks(page = 1, page_size = 20) {
|
export function getTasks(page = 1, page_size = 20) {
|
||||||
return authRequest({ url: '/api/app/task-center/tasks', method: 'GET', data: { page, page_size } })
|
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) {
|
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 } })
|
return authRequest({ url: `/api/app/users/${user_id}/shipments`, method: 'GET', data: { page, page_size } })
|
||||||
}
|
}
|
||||||
@ -163,14 +271,142 @@ export function getUserInvites(user_id, page = 1, page_size = 20) {
|
|||||||
// 兼容性适配接口 (适配 pages/mine/index.vue)
|
// 兼容性适配接口 (适配 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() {
|
export function getUserInfo() {
|
||||||
const user_info = uni.getStorageSync('user_info')
|
const user_info = uni.getStorageSync('user_info')
|
||||||
if (user_info) return Promise.resolve(user_info)
|
if (user_info) return Promise.resolve(user_info)
|
||||||
return authRequest({ url: '/api/app/users/info', method: 'GET' })
|
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 const getUserTasks = getTasks
|
||||||
export function getInviteRecords(page = 1, page_size = 20) {
|
export function getInviteRecords(page = 1, page_size = 20) {
|
||||||
const user_id = uni.getStorageSync('user_id')
|
const user_id = uni.getStorageSync('user_id')
|
||||||
return getUserInvites(user_id, page, page_size)
|
return getUserInvites(user_id, page, page_size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 对对碰游戏 (Matching Game) 接口
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始游戏
|
||||||
|
* @param {number} issue_id - 对应的活动期次ID
|
||||||
|
*/
|
||||||
|
export function startMatchingGame(issue_id) {
|
||||||
|
return authRequest({ url: '/api/app/matching/start', method: 'POST', data: { issue_id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行配对 (下一轮)
|
||||||
|
* @param {string} game_id - start接口返回的游戏ID
|
||||||
|
*/
|
||||||
|
export function playMatchingGame(game_id) {
|
||||||
|
return authRequest({ url: '/api/app/matching/play', method: 'POST', data: { game_id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有启用的卡牌配置
|
||||||
|
*/
|
||||||
|
export function getMatchingCardTypes() {
|
||||||
|
return authRequest({ url: '/api/app/matching/card_types', method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMatchingPreorder({ issue_id, position, coupon_id = 0, item_card_id = 0, use_game_pass = false }) {
|
||||||
|
return authRequest({
|
||||||
|
url: '/api/app/matching/preorder',
|
||||||
|
method: 'POST',
|
||||||
|
data: { issue_id, position, coupon_id, item_card_id, use_game_pass }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkMatchingGame(game_id, total_pairs) {
|
||||||
|
if (game_id && typeof game_id === 'object') {
|
||||||
|
total_pairs = game_id.total_pairs
|
||||||
|
game_id = game_id.game_id
|
||||||
|
}
|
||||||
|
return authRequest({
|
||||||
|
url: '/api/app/matching/check',
|
||||||
|
method: 'POST',
|
||||||
|
data: { game_id, total_pairs }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付成功后获取游戏数据
|
||||||
|
* @param {string} game_id - 游戏ID
|
||||||
|
*/
|
||||||
|
export function getMatchingGameCards(game_id) {
|
||||||
|
return authRequest({
|
||||||
|
url: '/api/app/matching/cards',
|
||||||
|
method: 'GET',
|
||||||
|
data: { game_id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 次数卡 (Game Pass) 接口
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户可用的次数卡
|
||||||
|
* @param {number} activity_id - 活动ID,不传返回所有
|
||||||
|
*/
|
||||||
|
export function getGamePasses(activity_id) {
|
||||||
|
const data = activity_id ? { activity_id } : {}
|
||||||
|
return authRequest({ url: '/api/app/game-passes/available', method: 'GET', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可购买的次数卡套餐
|
||||||
|
* @param {number} activity_id - 活动ID,不传返回全局套餐
|
||||||
|
*/
|
||||||
|
export function getGamePassPackages(activity_id) {
|
||||||
|
const data = activity_id ? { activity_id } : {}
|
||||||
|
return authRequest({ url: '/api/app/game-passes/packages', method: 'GET', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买次数卡套餐
|
||||||
|
* @param {number} package_id - 套餐ID
|
||||||
|
* @param {number} count - 购买数量
|
||||||
|
*/
|
||||||
|
export function purchaseGamePass(package_id, count = 1) {
|
||||||
|
return authRequest({ url: '/api/app/game-passes/purchase', method: 'POST', data: { package_id, count } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定抖音ID (Buyer ID)
|
||||||
|
* @param {string} douyin_id - 抖音号
|
||||||
|
*/
|
||||||
|
export function bindDouyinID(douyin_id) {
|
||||||
|
return authRequest({ url: '/api/app/users/douyin/bind', method: 'POST', data: { douyin_id } })
|
||||||
|
}
|
||||||
|
|||||||
338
components/BoxReveal.vue
Normal file
338
components/BoxReveal.vue
Normal 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>
|
||||||
@ -66,7 +66,7 @@ defineExpose({ revealResults, reset })
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* ============================================
|
/* ============================================
|
||||||
奇盒潮玩 - 翻牌动画组件
|
柯大鸭潮玩 - 翻牌动画组件
|
||||||
采用暖橙色调的开箱效果
|
采用暖橙色调的开箱效果
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@ -110,7 +110,8 @@ defineExpose({ revealResults, reset })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flip-card {
|
.flip-card {
|
||||||
perspective: 1000px;
|
perspective: 1200px;
|
||||||
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flip-inner {
|
.flip-inner {
|
||||||
@ -118,11 +119,18 @@ defineExpose({ revealResults, reset })
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 220rpx;
|
height: 220rpx;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
|
-webkit-transform-style: preserve-3d;
|
||||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flip-card.flipped .flip-inner {
|
.flip-card.flipped .flip-inner {
|
||||||
transform: rotateY(180deg);
|
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 {
|
.flip-front, .flip-back {
|
||||||
@ -130,6 +138,7 @@ defineExpose({ revealResults, reset })
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@ -155,6 +164,19 @@ defineExpose({ revealResults, reset })
|
|||||||
50% { opacity: 1; transform: scale(1.05); }
|
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 {
|
.flip-back {
|
||||||
background: $bg-card;
|
background: $bg-card;
|
||||||
transform: rotateY(180deg);
|
transform: rotateY(180deg);
|
||||||
|
|||||||
542
components/GamePassPurchasePopup.vue
Normal file
542
components/GamePassPurchasePopup.vue
Normal file
@ -0,0 +1,542 @@
|
|||||||
|
<template>
|
||||||
|
<view>
|
||||||
|
<view v-if="visible" class="popup-mask" @tap="handleClose">
|
||||||
|
<view class="popup-content" @tap.stop>
|
||||||
|
<view class="popup-header">
|
||||||
|
<text class="title">使用次数<text class="title-sub">(次数需使用完,剩余次数不可退)</text></text>
|
||||||
|
<view class="close-btn" @tap="handleClose">×</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y class="packages-list">
|
||||||
|
<view v-if="loading" class="loading-state">
|
||||||
|
<text>加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!packages.length" class="empty-state">
|
||||||
|
<text>暂无优惠套餐</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
v-for="(pkg, index) in packages"
|
||||||
|
:key="pkg.id"
|
||||||
|
class="package-item"
|
||||||
|
:class="{ 'best-value': pkg.is_best_value }"
|
||||||
|
@tap="handlePurchase(pkg)"
|
||||||
|
>
|
||||||
|
<view class="pkg-tag" v-if="pkg.tag">{{ pkg.tag }}</view>
|
||||||
|
<view class="pkg-left">
|
||||||
|
<view class="pkg-name">{{ pkg.name }}</view>
|
||||||
|
<view class="pkg-count">含 {{ pkg.pass_count }} 次游戏</view>
|
||||||
|
<view class="pkg-validity" v-if="pkg.valid_days > 0">有效期 {{ pkg.valid_days }} 天</view>
|
||||||
|
<view class="pkg-validity" v-else>永久有效</view>
|
||||||
|
</view>
|
||||||
|
<view class="pkg-right">
|
||||||
|
<view class="pkg-price-row">
|
||||||
|
<text class="currency">¥</text>
|
||||||
|
<text class="price">{{ (pkg.price / 100).toFixed(2) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="pkg-original-price" v-if="pkg.original_price > pkg.price">
|
||||||
|
¥{{ (pkg.original_price / 100).toFixed(2) }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="action-row">
|
||||||
|
<view class="stepper" @tap.stop>
|
||||||
|
<text class="step-btn minus" @tap="updateCount(pkg.id, -1)">-</text>
|
||||||
|
<input
|
||||||
|
class="step-input"
|
||||||
|
type="number"
|
||||||
|
:value="counts[pkg.id] || 1"
|
||||||
|
@input="onInputCount(pkg.id, $event)"
|
||||||
|
@blur="onBlurCount(pkg.id)"
|
||||||
|
/>
|
||||||
|
<text class="step-btn plus" @tap="updateCount(pkg.id, 1)">+</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn-buy" :loading="purchasingId === pkg.id" @tap.stop="handlePurchase(pkg)">
|
||||||
|
购买
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { getGamePassPackages, purchaseGamePass, createWechatOrder } from '@/api/appUser'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
activityId: { type: [String, Number], default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'success'])
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const packages = ref([])
|
||||||
|
const purchasingId = ref(null)
|
||||||
|
const counts = ref({})
|
||||||
|
|
||||||
|
function updateCount(pkgId, delta) {
|
||||||
|
const current = counts.value[pkgId] || 1
|
||||||
|
const newVal = current + delta
|
||||||
|
if (newVal >= 1 && newVal <= 200) {
|
||||||
|
counts.value[pkgId] = newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputCount(pkgId, e) {
|
||||||
|
const val = parseInt(e.detail.value) || 1
|
||||||
|
// 允许输入过程中的临时值(如空字符串),但限制范围
|
||||||
|
if (val >= 1 && val <= 200) {
|
||||||
|
counts.value[pkgId] = val
|
||||||
|
} else if (val < 1) {
|
||||||
|
counts.value[pkgId] = 1
|
||||||
|
} else if (val > 200) {
|
||||||
|
counts.value[pkgId] = 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlurCount(pkgId) {
|
||||||
|
// 失去焦点时确保值在有效范围内
|
||||||
|
const current = counts.value[pkgId] || 1
|
||||||
|
if (current < 1) {
|
||||||
|
counts.value[pkgId] = 1
|
||||||
|
} else if (current > 200) {
|
||||||
|
counts.value[pkgId] = 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.visible, (val) => {
|
||||||
|
if (val) {
|
||||||
|
fetchPackages()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchPackages() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getGamePassPackages(props.activityId)
|
||||||
|
// res 应该是数组
|
||||||
|
let list = []
|
||||||
|
if (Array.isArray(res)) list = res
|
||||||
|
else if (res && Array.isArray(res.packages)) list = res.packages
|
||||||
|
else if (res && Array.isArray(res.list)) list = res.list
|
||||||
|
else if (res && Array.isArray(res.data)) list = res.data
|
||||||
|
|
||||||
|
// 初始化counts
|
||||||
|
const countMap = {}
|
||||||
|
list.forEach(p => countMap[p.id] = 1)
|
||||||
|
counts.value = countMap
|
||||||
|
|
||||||
|
// 简单处理:给第一个或最优惠的打标签
|
||||||
|
// 这里随机模拟一下 "热销" 或计算折扣力度
|
||||||
|
packages.value = list.map(p => {
|
||||||
|
let tag = ''
|
||||||
|
const discount = 1 - (p.price / p.original_price)
|
||||||
|
if (p.original_price > 0 && discount >= 0.2) {
|
||||||
|
tag = `省${Math.floor(discount * 100)}%`
|
||||||
|
}
|
||||||
|
return { ...p, tag }
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
packages.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePurchase(pkg) {
|
||||||
|
if (purchasingId.value) return
|
||||||
|
purchasingId.value = pkg.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
uni.showLoading({ title: '创建订单...' })
|
||||||
|
|
||||||
|
// 1. 调用购买接口 (后端创建订单 + 预下单)
|
||||||
|
// 注意:根据后端实现,purchaseGamePass 可能直接返回支付参数,或者需要我们自己调 createWechatOrder
|
||||||
|
// 之前分析 game_passes_app.go,它似乎返回的是 simple success?
|
||||||
|
// 让我们再确认一下 game_passes_app.go 的 PurchaseGamePassPackage
|
||||||
|
// 既然我看不到代码,按常规逻辑:
|
||||||
|
// 如果返回 order_no,则需要 createWechatOrder
|
||||||
|
// 如果返回 pay_params,则直接支付
|
||||||
|
|
||||||
|
// 假设 API 返回 { order_no, ... }
|
||||||
|
const count = counts.value[pkg.id] || 1
|
||||||
|
const res = await purchaseGamePass(pkg.id, count)
|
||||||
|
const orderNo = res.order_no || res.orderNo
|
||||||
|
if (!orderNo) throw new Error('下单失败')
|
||||||
|
|
||||||
|
// 2. 拉起支付
|
||||||
|
const openid = uni.getStorageSync('openid')
|
||||||
|
const payRes = await createWechatOrder({ openid, order_no: orderNo })
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
uni.requestPayment({
|
||||||
|
provider: 'wxpay',
|
||||||
|
timeStamp: payRes.timeStamp || payRes.timestamp,
|
||||||
|
nonceStr: payRes.nonceStr || payRes.noncestr,
|
||||||
|
package: payRes.package,
|
||||||
|
signType: payRes.signType || 'RSA',
|
||||||
|
paySign: payRes.paySign,
|
||||||
|
success: resolve,
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
uni.showToast({ title: '购买成功', icon: 'success' })
|
||||||
|
emit('success')
|
||||||
|
handleClose()
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.errMsg && e.errMsg.includes('cancel')) {
|
||||||
|
uni.showToast({ title: '取消支付', icon: 'none' })
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: e.message || '购买失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
purchasingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.popup-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(10rpx);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
animation: fadeIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-content {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFF 100%);
|
||||||
|
border-radius: 40rpx 40rpx 0 0;
|
||||||
|
box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.12);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
max-height: 82vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
padding: 40rpx 32rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: 2rpx solid #F0F2F5;
|
||||||
|
background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFF 100%);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -8rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 80rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
background: #E5E7EB;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-sub {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #9CA3AF;
|
||||||
|
margin-left: 8rpx;
|
||||||
|
-webkit-text-fill-color: #9CA3AF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
font-size: 52rpx;
|
||||||
|
color: #CBD5E1;
|
||||||
|
line-height: 0.8;
|
||||||
|
padding: 8rpx;
|
||||||
|
font-weight: 200;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: #667EEA;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.packages-list {
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
max-height: 62vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80rpx 0;
|
||||||
|
color: #9CA3AF;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '📦';
|
||||||
|
display: block;
|
||||||
|
font-size: 88rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-item {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(145deg, #FFFFFF 0%, #F8F9FF 100%);
|
||||||
|
border: 2rpx solid #E8EEFF;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.08);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 6rpx;
|
||||||
|
background: linear-gradient(90deg, #667EEA 0%, #764BA2 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(102, 126, 234, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
|
||||||
|
color: #FFF;
|
||||||
|
font-size: 20rpx;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-bottom-right-radius: 16rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(255, 107, 107, 0.3);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-left {
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-name {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F2937;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
letter-spacing: 0.3rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-count {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6B7280;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '🎮';
|
||||||
|
font-size: 22rpx;
|
||||||
|
margin-right: 6rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-validity {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9CA3AF;
|
||||||
|
font-weight: 400;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '⏰';
|
||||||
|
font-size: 18rpx;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
min-width: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-price-row {
|
||||||
|
color: #FF6B6B;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
letter-spacing: -0.5rpx;
|
||||||
|
|
||||||
|
.currency {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 2rpx;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size: 44rpx;
|
||||||
|
text-shadow: 0 2rpx 8rpx rgba(255, 107, 107, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-original-price {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #CBD5E1;
|
||||||
|
text-decoration: line-through;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-buy {
|
||||||
|
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
|
||||||
|
color: #FFF;
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
line-height: 60rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
border: none;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 6rpx 20rpx rgba(102, 126, 234, 0.35);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
|
||||||
|
&[loading] {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(145deg, #F1F3F9 0%, #E8EEFF 100%);
|
||||||
|
border: 2rpx solid #E0E7FF;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 4rpx;
|
||||||
|
box-shadow: inset 0 2rpx 6rpx rgba(102, 126, 234, 0.06);
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #667EEA;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.minus {
|
||||||
|
color: #9CA3AF;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: #667EEA;
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-input {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
line-height: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F2937;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
248
components/MatchingGame.vue
Normal file
248
components/MatchingGame.vue
Normal 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>
|
||||||
@ -1,4 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<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 v-if="visible" class="payment-popup-mask" @tap="handleMaskClick">
|
<view v-if="visible" class="payment-popup-mask" @tap="handleMaskClick">
|
||||||
<view class="payment-popup-content" @tap.stop>
|
<view class="payment-popup-content" @tap.stop>
|
||||||
<!-- 顶部提示 -->
|
<!-- 顶部提示 -->
|
||||||
@ -13,23 +32,53 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="popup-body">
|
<view class="popup-body">
|
||||||
<view class="amount-section" v-if="amount !== undefined && amount !== null">
|
<!-- 次数卡选项(有数据时显示) -->
|
||||||
|
<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>
|
||||||
|
<view v-if="!useGamePass" class="divider-line">
|
||||||
|
<text class="divider-text">或选择其他支付方式</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="amount-section" v-if="!useGamePass && amount !== undefined && amount !== null">
|
||||||
<text class="label">支付金额</text>
|
<text class="label">支付金额</text>
|
||||||
<text class="amount">¥{{ amount }}</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>
|
||||||
|
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
<text class="label">优惠券</text>
|
<text class="label">优惠券</text>
|
||||||
<picker
|
<picker
|
||||||
|
class="picker-full"
|
||||||
mode="selector"
|
mode="selector"
|
||||||
:range="coupons"
|
:range="coupons"
|
||||||
range-key="name"
|
range-key="name"
|
||||||
@change="onCouponChange"
|
@change="onCouponChange"
|
||||||
:value="couponIndex"
|
:value="couponIndex"
|
||||||
:disabled="!coupons || coupons.length === 0"
|
:disabled="(!coupons || coupons.length === 0) || useGamePass"
|
||||||
>
|
>
|
||||||
<view class="picker-display">
|
<view class="picker-display" :class="{ 'picker-disabled': useGamePass }">
|
||||||
<text v-if="selectedCoupon" class="selected-text">{{ selectedCoupon.name }} (-¥{{ selectedCoupon.amount }})</text>
|
<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-if="!coupons || coupons.length === 0" class="placeholder">暂无优惠券可用</text>
|
||||||
<text v-else class="placeholder">请选择优惠券</text>
|
<text v-else class="placeholder">请选择优惠券</text>
|
||||||
<text class="arrow"></text>
|
<text class="arrow"></text>
|
||||||
@ -40,16 +89,20 @@
|
|||||||
<view class="form-item" v-if="showCards">
|
<view class="form-item" v-if="showCards">
|
||||||
<text class="label">道具卡</text>
|
<text class="label">道具卡</text>
|
||||||
<picker
|
<picker
|
||||||
|
class="picker-full"
|
||||||
mode="selector"
|
mode="selector"
|
||||||
:range="propCards"
|
:range="displayCards"
|
||||||
range-key="name"
|
range-key="displayName"
|
||||||
@change="onCardChange"
|
@change="onCardChange"
|
||||||
:value="cardIndex"
|
:value="cardIndex"
|
||||||
:disabled="!propCards || propCards.length === 0"
|
:disabled="!displayCards || displayCards.length === 0"
|
||||||
>
|
>
|
||||||
<view class="picker-display">
|
<view class="picker-display">
|
||||||
<text v-if="selectedCard" class="selected-text">{{ selectedCard.name }}</text>
|
<text v-if="selectedCard" class="selected-text">
|
||||||
<text v-else-if="!propCards || propCards.length === 0" class="placeholder">暂无道具卡可用</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 v-else class="placeholder">请选择道具卡</text>
|
||||||
<text class="arrow"></text>
|
<text class="arrow"></text>
|
||||||
</view>
|
</view>
|
||||||
@ -59,7 +112,9 @@
|
|||||||
|
|
||||||
<view class="popup-footer">
|
<view class="popup-footer">
|
||||||
<button class="btn-cancel" @tap="handleClose">取消</button>
|
<button class="btn-cancel" @tap="handleClose">取消</button>
|
||||||
<button class="btn-confirm" @tap="handleConfirm">确认支付</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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -73,13 +128,101 @@ const props = defineProps({
|
|||||||
amount: { type: [Number, String], default: 0 },
|
amount: { type: [Number, String], default: 0 },
|
||||||
coupons: { type: Array, default: () => [] },
|
coupons: { type: Array, default: () => [] },
|
||||||
propCards: { 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 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 couponIndex = ref(-1)
|
||||||
const cardIndex = 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(() => {
|
const selectedCoupon = computed(() => {
|
||||||
if (couponIndex.value >= 0 && props.coupons[couponIndex.value]) {
|
if (couponIndex.value >= 0 && props.coupons[couponIndex.value]) {
|
||||||
@ -88,20 +231,53 @@ const selectedCoupon = computed(() => {
|
|||||||
return null
|
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(() => {
|
const selectedCard = computed(() => {
|
||||||
if (cardIndex.value >= 0 && props.propCards[cardIndex.value]) {
|
if (cardIndex.value >= 0 && displayCards.value[cardIndex.value]) {
|
||||||
return props.propCards[cardIndex.value]
|
return displayCards.value[cardIndex.value]
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
const finalPayAmount = computed(() => {
|
||||||
if (val) {
|
const amt = Number(props.amount) || 0
|
||||||
couponIndex.value = -1
|
return Math.max(0, amt - effectiveCouponDiscount.value).toFixed(2)
|
||||||
cardIndex.value = -1
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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) {
|
function onCouponChange(e) {
|
||||||
couponIndex.value = e.detail.value
|
couponIndex.value = e.detail.value
|
||||||
}
|
}
|
||||||
@ -112,7 +288,7 @@ function onCardChange(e) {
|
|||||||
|
|
||||||
function openAgreement() {
|
function openAgreement() {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: '/pages/agreement/purchase' // 假设协议页面路径,如果没有请替换为实际路径
|
url: '/pages-user/agreement/purchase'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,15 +303,330 @@ function handleClose() {
|
|||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
emit('confirm', {
|
emit('confirm', {
|
||||||
coupon: selectedCoupon.value,
|
coupon: useGamePass.value ? null : selectedCoupon.value,
|
||||||
card: props.showCards ? selectedCard.value : null
|
card: (props.showCards && !useGamePass.value) ? selectedCard.value : null,
|
||||||
|
useGamePass: useGamePass.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 + constant(safe-area-inset-bottom));
|
||||||
padding-bottom: calc($spacing-lg + env(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);
|
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
from { transform: translateY(100%); }
|
from { transform: translateY(100%); }
|
||||||
to { transform: translateY(0); }
|
to { transform: translateY(0); }
|
||||||
@ -250,6 +743,11 @@ function handleConfirm() {
|
|||||||
margin-bottom: $spacing-xs;
|
margin-bottom: $spacing-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picker-full {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.picker-display {
|
.picker-display {
|
||||||
border: 2rpx solid $border-color-light;
|
border: 2rpx solid $border-color-light;
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
@ -317,4 +815,120 @@ function handleConfirm() {
|
|||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
box-shadow: $shadow-md;
|
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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
204
components/SplashScreen.vue
Normal file
204
components/SplashScreen.vue
Normal 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>
|
||||||
@ -1,5 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="choice-grid-container">
|
<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-if="loading" class="loading-state">加载中...</view>
|
||||||
<view v-else-if="!choices || choices.length === 0" class="empty-state">暂无可选位置</view>
|
<view v-else-if="!choices || choices.length === 0" class="empty-state">暂无可选位置</view>
|
||||||
|
|
||||||
@ -25,7 +32,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="action-bar">
|
<view class="action-bar" v-if="!hideActionBar">
|
||||||
<view class="selection-info" v-if="selectedItems.length > 0">
|
<view class="selection-info" v-if="selectedItems.length > 0">
|
||||||
已选 <text class="highlight">{{ selectedItems.length }}</text> 个位置
|
已选 <text class="highlight">{{ selectedItems.length }}</text> 个位置
|
||||||
</view>
|
</view>
|
||||||
@ -34,42 +41,44 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="action-buttons">
|
<view class="action-buttons">
|
||||||
<button v-if="selectedItems.length === 0" class="btn-common btn-random" @tap="handleRandomOne">随机一发</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">去支付</button>
|
<button v-else class="btn-common btn-buy" @tap="handleBuy" :disabled="disabled">去支付</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 支付弹窗 -->
|
<!-- 支付弹窗已移至父组件,避免在 scroll-view 内导致定位问题 -->
|
||||||
<PaymentPopup
|
|
||||||
v-model:visible="paymentVisible"
|
|
||||||
:amount="totalAmount"
|
|
||||||
:coupons="coupons"
|
|
||||||
:showCards="false"
|
|
||||||
@confirm="onPaymentConfirm"
|
|
||||||
/>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { getIssueChoices, getUserCoupons, joinLottery, createWechatOrder, getLotteryResult } from '@/api/appUser'
|
import { getIssueChoices, getUserCoupons, joinLottery, createWechatOrder, getLotteryResult } from '@/api/appUser'
|
||||||
import PaymentPopup from '@/components/PaymentPopup.vue'
|
|
||||||
import { requestLotterySubscription } from '@/utils/subscribe'
|
import { requestLotterySubscription } from '@/utils/subscribe'
|
||||||
|
|
||||||
|
console.log('[YifanSelector] Script setup running!')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
activityId: { type: [String, Number], required: true },
|
activityId: { type: [String, Number], required: true },
|
||||||
issueId: { 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 choices = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selectedItems = ref([])
|
const selectedItems = ref([])
|
||||||
const paymentVisible = ref(false)
|
const paymentVisible = ref(false)
|
||||||
|
|
||||||
|
// 监听支付弹窗状态变化,通知父组件
|
||||||
|
watch(paymentVisible, (newVal) => {
|
||||||
|
emit('payment-visible-change', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
// 模拟优惠券和道具卡数据,实际项目中可能需要从接口获取
|
// 模拟优惠券和道具卡数据,实际项目中可能需要从接口获取
|
||||||
const coupons = ref([])
|
const coupons = ref([])
|
||||||
|
|
||||||
@ -77,23 +86,65 @@ const totalAmount = computed(() => {
|
|||||||
return (selectedItems.value.length * props.pricePerDraw).toFixed(2)
|
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) => {
|
watch(() => props.issueId, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
loadChoices()
|
loadChoices()
|
||||||
selectedItems.value = []
|
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(() => {
|
onMounted(() => {
|
||||||
|
console.log('[YifanSelector] Component mounted', {
|
||||||
|
activityId: props.activityId,
|
||||||
|
issueId: props.issueId
|
||||||
|
})
|
||||||
if (props.issueId) {
|
if (props.issueId) {
|
||||||
loadChoices()
|
loadChoices()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function 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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
console.log('[YifanSelector] Calling getIssueChoices API...')
|
||||||
const res = await getIssueChoices(props.activityId, props.issueId)
|
const res = await getIssueChoices(props.activityId, props.issueId)
|
||||||
|
console.log('[YifanSelector] getIssueChoices response:', res)
|
||||||
|
|
||||||
// 处理 { total_slots: 1, available: [1], claimed: [] } 这种格式
|
// 处理 { total_slots: 1, available: [1], claimed: [] } 这种格式
|
||||||
if (res && typeof res.total_slots === 'number' && Array.isArray(res.available)) {
|
if (res && typeof res.total_slots === 'number' && Array.isArray(res.available)) {
|
||||||
@ -124,6 +175,7 @@ async function loadChoices() {
|
|||||||
} else {
|
} else {
|
||||||
choices.value = []
|
choices.value = []
|
||||||
}
|
}
|
||||||
|
console.log('[YifanSelector] Choices processed, total:', choices.value.length)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load choices:', error)
|
console.error('Failed to load choices:', error)
|
||||||
uni.showToast({ title: '加载位置失败', icon: 'none' })
|
uni.showToast({ title: '加载位置失败', icon: 'none' })
|
||||||
@ -137,6 +189,10 @@ function isSelected(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect(item) {
|
function handleSelect(item) {
|
||||||
|
if (disabled.value) {
|
||||||
|
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (item.status === 'sold' || item.is_sold) {
|
if (item.status === 'sold' || item.is_sold) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -147,15 +203,29 @@ function handleSelect(item) {
|
|||||||
} else {
|
} else {
|
||||||
selectedItems.value.push(item)
|
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
|
if (selectedItems.value.length === 0) return
|
||||||
|
|
||||||
|
// 主动发送金额和优惠券数据
|
||||||
|
emit('payment-amount-change', totalAmount.value)
|
||||||
|
await fetchCoupons()
|
||||||
paymentVisible.value = true
|
paymentVisible.value = true
|
||||||
fetchCoupons()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRandomOne() {
|
async function handleRandomOne() {
|
||||||
|
if (disabled.value) {
|
||||||
|
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
const available = choices.value.filter(item =>
|
const available = choices.value.filter(item =>
|
||||||
!item.is_sold && item.status !== 'sold' && !isSelected(item)
|
!item.is_sold && item.status !== 'sold' && !isSelected(item)
|
||||||
)
|
)
|
||||||
@ -171,9 +241,10 @@ function handleRandomOne() {
|
|||||||
// 选中该位置
|
// 选中该位置
|
||||||
selectedItems.value.push(randomItem)
|
selectedItems.value.push(randomItem)
|
||||||
|
|
||||||
// 立即弹出支付
|
// 主动发送金额和优惠券数据
|
||||||
|
emit('payment-amount-change', totalAmount.value)
|
||||||
|
await fetchCoupons()
|
||||||
paymentVisible.value = true
|
paymentVisible.value = true
|
||||||
fetchCoupons()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -195,13 +266,21 @@ async function fetchCoupons() {
|
|||||||
amount: Number(yuan).toFixed(2)
|
amount: Number(yuan).toFixed(2)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// 主动发送优惠券数据给父组件
|
||||||
|
emit('payment-coupons-change', coupons.value)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('fetchCoupons error', e)
|
console.error('fetchCoupons error', e)
|
||||||
coupons.value = []
|
coupons.value = []
|
||||||
|
emit('payment-coupons-change', [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPaymentConfirm(paymentData) {
|
async function onPaymentConfirm(paymentData) {
|
||||||
|
if (disabled.value) {
|
||||||
|
paymentVisible.value = false
|
||||||
|
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
paymentVisible.value = false
|
paymentVisible.value = false
|
||||||
|
|
||||||
const selectedSlots = selectedItems.value.map(item => item.id || item.position)
|
const selectedSlots = selectedItems.value.map(item => item.id || item.position)
|
||||||
@ -230,6 +309,7 @@ async function onPaymentConfirm(paymentData) {
|
|||||||
channel: 'miniapp',
|
channel: 'miniapp',
|
||||||
count: selectedSlots.length,
|
count: selectedSlots.length,
|
||||||
coupon_id: paymentData.coupon ? Number(paymentData.coupon.id) : 0,
|
coupon_id: paymentData.coupon ? Number(paymentData.coupon.id) : 0,
|
||||||
|
use_game_pass: !!paymentData.useGamePass,
|
||||||
slot_index: selectedSlots.map(Number)
|
slot_index: selectedSlots.map(Number)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,6 +322,10 @@ async function onPaymentConfirm(paymentData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 使用返回的订单号去发起支付
|
// 2. 使用返回的订单号去发起支付
|
||||||
|
// 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({
|
const payRes = await createWechatOrder({
|
||||||
openid: openid,
|
openid: openid,
|
||||||
order_no: orderNo
|
order_no: orderNo
|
||||||
@ -260,6 +344,7 @@ async function onPaymentConfirm(paymentData) {
|
|||||||
fail: reject
|
fail: reject
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
uni.showLoading({ title: '查询结果...' })
|
uni.showLoading({ title: '查询结果...' })
|
||||||
@ -292,11 +377,22 @@ async function onPaymentConfirm(paymentData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 暴露方法供外部调用
|
||||||
|
defineExpose({
|
||||||
|
handleRandomOne,
|
||||||
|
handleBuy,
|
||||||
|
onPaymentConfirm,
|
||||||
|
setPaymentVisible: (visible) => {
|
||||||
|
paymentVisible.value = visible
|
||||||
|
},
|
||||||
|
selectedItems: () => selectedItems.value
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* ============================================
|
/* ============================================
|
||||||
奇盒潮玩 - 选号组件 (适配高级卡片布局)
|
柯大鸭潮玩 - 选号组件 (适配高级卡片布局)
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
/* 容器 - 去除背景,融入父级卡片 */
|
/* 容器 - 去除背景,融入父级卡片 */
|
||||||
@ -326,8 +422,7 @@ async function onPaymentConfirm(paymentData) {
|
|||||||
|
|
||||||
/* 网格包装 */
|
/* 网格包装 */
|
||||||
.grid-wrapper {
|
.grid-wrapper {
|
||||||
padding-bottom: 200rpx; /* 留出底部操作栏空间 */
|
padding: 0 20rpx 140rpx; /* 减少底部padding */
|
||||||
padding: 0 20rpx 200rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 号码网格 - 调整为更合理的列数,适配不同屏幕 */
|
/* 号码网格 - 调整为更合理的列数,适配不同屏幕 */
|
||||||
@ -448,39 +543,40 @@ async function onPaymentConfirm(paymentData) {
|
|||||||
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
|
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============= 底部操作栏 ============= */
|
/* ============= 底部操作栏 - 对对碰风格胶囊浮动 ============= */
|
||||||
.action-bar {
|
.action-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
left: 32rpx;
|
||||||
|
right: 32rpx;
|
||||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||||
left: 30rpx;
|
background: rgba(255, 255, 255, 0.85);
|
||||||
right: 30rpx;
|
backdrop-filter: blur(30rpx);
|
||||||
background: rgba($bg-card, 0.9);
|
padding: 24rpx 40rpx;
|
||||||
backdrop-filter: blur(20rpx);
|
|
||||||
padding: 20rpx 30rpx;
|
|
||||||
box-shadow: $shadow-lg;
|
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
border: 1rpx solid rgba($bg-card, 0.5);
|
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
|
||||||
animation: slideUp 0.4s ease-out backwards;
|
border: 1rpx solid rgba(255, 255, 255, 0.6);
|
||||||
|
animation: slideUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) backwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 选择信息行 */
|
/* 选择信息行 */
|
||||||
.selection-info {
|
.selection-info {
|
||||||
font-size: 26rpx;
|
font-size: 28rpx;
|
||||||
color: $text-main;
|
color: $text-main;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
font-weight: 600;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.highlight {
|
.highlight {
|
||||||
color: $brand-primary;
|
color: $brand-primary;
|
||||||
font-weight: 800;
|
font-weight: 900;
|
||||||
font-size: 36rpx;
|
font-size: 40rpx;
|
||||||
margin: 0 8rpx;
|
margin: 0 8rpx;
|
||||||
|
font-family: 'DIN Alternate', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮组 */
|
/* 按钮组 */
|
||||||
@ -491,54 +587,79 @@ async function onPaymentConfirm(paymentData) {
|
|||||||
|
|
||||||
/* 通用按钮样式 */
|
/* 通用按钮样式 */
|
||||||
.btn-common {
|
.btn-common {
|
||||||
height: 80rpx;
|
height: 88rpx;
|
||||||
line-height: 80rpx;
|
line-height: 88rpx;
|
||||||
padding: 0 48rpx;
|
padding: 0 56rpx;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
font-size: 28rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 700;
|
font-weight: 900;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
&:active {
|
||||||
transform: scale(0.96);
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 购买按钮 */
|
/* 购买按钮 - 品牌渐变 + 流光 */
|
||||||
.btn-buy {
|
.btn-buy {
|
||||||
background: $gradient-brand !important;
|
background: $gradient-brand !important;
|
||||||
color: #FFFFFF !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;
|
||||||
|
|
||||||
/* 脉冲动画 */
|
&::before {
|
||||||
animation: pulse 2s infinite;
|
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 {
|
.btn-random {
|
||||||
background: $bg-secondary !important;
|
background: #1A1A1A !important;
|
||||||
color: $text-main !important;
|
color: $accent-gold !important;
|
||||||
box-shadow: none;
|
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.15);
|
||||||
border: 1rpx solid transparent;
|
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background: #E5E7EB !important;
|
background: #333 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes btnShine {
|
||||||
|
0% { left: -150%; }
|
||||||
|
100% { left: 150%; }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
from { transform: translateY(100%); opacity: 0; }
|
from { transform: translateY(120rpx); opacity: 0; }
|
||||||
to { transform: translateY(0); opacity: 1; }
|
to { transform: translateY(0); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes scaleIn {
|
||||||
0% { box-shadow: 0 0 0 0 rgba($brand-primary, 0.4); }
|
from { transform: scale(0.95); opacity: 0; }
|
||||||
70% { box-shadow: 0 0 0 20rpx rgba($brand-primary, 0); }
|
to { transform: scale(1); opacity: 1; }
|
||||||
100% { box-shadow: 0 0 0 0 rgba($brand-primary, 0); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
|
|||||||
262
components/activity/ActivityHeader.vue
Normal file
262
components/activity/ActivityHeader.vue
Normal 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
Normal file
147
components/activity/ActivityPageLayout.vue
Normal 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
Normal file
118
components/activity/ActivityTabs.vue
Normal 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
Normal file
228
components/activity/CabinetPreviewPopup.vue
Normal 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
Normal file
319
components/activity/DrawLoadingPopup.vue
Normal 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
Normal file
502
components/activity/LotteryResultPopup.vue
Normal 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>
|
||||||
239
components/activity/RecordsList.vue
Normal file
239
components/activity/RecordsList.vue
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
<template>
|
||||||
|
<view class="records-wrapper">
|
||||||
|
<view class="records-list" v-if="records && records.length">
|
||||||
|
<view v-for="(item, idx) in records" :key="item.id ? `${item.id}_${idx}` : idx" class="record-item">
|
||||||
|
<!-- 用户信息 (左侧, 紧凑) -->
|
||||||
|
<view class="user-info-section">
|
||||||
|
<image class="user-avatar" :src="item.avatar || defaultAvatar" mode="aspectFill" />
|
||||||
|
<view class="user-detail">
|
||||||
|
<text class="user-name">{{ item.user_name }}</text>
|
||||||
|
<text class="record-time">{{ formatTime(item.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 奖品信息 (右侧, 扩展) -->
|
||||||
|
<view class="prize-info-section">
|
||||||
|
<view class="prize-image-wrap">
|
||||||
|
<image class="record-img" :src="item.image" mode="aspectFill" />
|
||||||
|
<view class="level-badge" v-if="item.level_name">{{ item.level_name }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="record-info">
|
||||||
|
<view class="record-title">{{ item.title }}</view>
|
||||||
|
<view class="record-meta">
|
||||||
|
<text class="record-count">x1</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="empty-state-compact" v-else>
|
||||||
|
<view class="empty-icon-wrap">
|
||||||
|
<text class="empty-icon">🎁</text>
|
||||||
|
</view>
|
||||||
|
<text class="empty-title">{{ emptyText }}</text>
|
||||||
|
<text class="empty-hint">快来参与活动获得奖品吧</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
records: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: '暂无购买记录'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultAvatar = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
|
||||||
|
|
||||||
|
function formatTime(t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const d = new Date(t)
|
||||||
|
if (isNaN(d.getTime())) return t // 如果解析失败直接返回
|
||||||
|
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||||
|
return `${m}-${day} ${hh}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.records-list {
|
||||||
|
padding: $spacing-xs 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: $spacing-md $spacing-sm;
|
||||||
|
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: $spacing-md $spacing-sm;
|
||||||
|
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
flex: 0 0 35%; // 固定宽度给用户信息
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $bg-secondary;
|
||||||
|
border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-main;
|
||||||
|
font-weight: 500;
|
||||||
|
@include text-ellipsis(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-time {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
@include text-ellipsis(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-info-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-image-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: $bg-secondary;
|
||||||
|
border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6rpx;
|
||||||
|
right: -6rpx;
|
||||||
|
background: $gradient-gold;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16rpx;
|
||||||
|
padding: 2rpx 6rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start; // 左对齐
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-title {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-main;
|
||||||
|
@include text-ellipsis(2); // 允许两行
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-count {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $brand-primary;
|
||||||
|
background: rgba($brand-primary, 0.08);
|
||||||
|
padding: 2rpx 8rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 紧凑优雅的空状态 */
|
||||||
|
.empty-state-compact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $spacing-lg $spacing-xl;
|
||||||
|
min-height: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-wrap {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, rgba($brand-primary, 0.1) 0%, rgba($accent-gold, 0.1) 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sub;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
219
components/activity/RewardsPopup.vue
Normal file
219
components/activity/RewardsPopup.vue
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="visible" class="rewards-overlay" @touchmove.stop.prevent>
|
||||||
|
<view class="rewards-mask" @tap="$emit('update:visible', false)"></view>
|
||||||
|
<view class="rewards-panel" @tap.stop>
|
||||||
|
<view class="rewards-header">
|
||||||
|
<text class="rewards-title">{{ title }}</text>
|
||||||
|
<text class="rewards-close" @tap="$emit('update:visible', false)">×</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y class="rewards-list">
|
||||||
|
<view v-if="rewardGroups.length > 0">
|
||||||
|
<view class="rewards-group-v2" v-for="group in rewardGroups" :key="group.level">
|
||||||
|
<view class="group-header-row">
|
||||||
|
<text class="group-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">{{ group.level }}赏</text>
|
||||||
|
<text class="group-total-prob">该档总概率 {{ group.totalPercent }}%</text>
|
||||||
|
</view>
|
||||||
|
<view v-for="(item, idx) in group.rewards" :key="item.id || idx" class="rewards-item">
|
||||||
|
<image class="rewards-thumb" :src="item.image" mode="aspectFill" />
|
||||||
|
<view class="rewards-info">
|
||||||
|
<view class="rewards-name-row">
|
||||||
|
<text class="rewards-name">{{ item.title || '-' }}</text>
|
||||||
|
<view class="rewards-tag" v-if="item.boss">BOSS</view>
|
||||||
|
</view>
|
||||||
|
<text class="rewards-percent">单项概率 {{ formatPercent(item.percent) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="rewards-empty">{{ emptyText }}</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { formatPercent } from '@/utils/format'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '奖品与概率'
|
||||||
|
},
|
||||||
|
rewardGroups: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: '暂无奖品数据'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:visible'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.rewards-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-panel {
|
||||||
|
position: relative;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: $bg-card;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(50rpx);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
border-bottom: 1rpx solid $border-color-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-close {
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
line-height: 1;
|
||||||
|
padding: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-list {
|
||||||
|
max-height: 60vh;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-group-v2 {
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-badge {
|
||||||
|
font-size: $font-xs;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $brand-primary;
|
||||||
|
background: rgba($brand-primary, 0.1);
|
||||||
|
padding: 4rpx $spacing-sm;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
|
||||||
|
&.badge-boss {
|
||||||
|
background: $gradient-gold;
|
||||||
|
color: #6b4b1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-total-prob {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $spacing-sm 0;
|
||||||
|
border-bottom: 1rpx solid rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-thumb {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 100rpx;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
margin-right: $spacing-md;
|
||||||
|
background: $bg-secondary;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
margin-bottom: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-name {
|
||||||
|
font-size: $font-md;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-main;
|
||||||
|
@include text-ellipsis(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-tag {
|
||||||
|
font-size: $font-xxs;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6b4b1f;
|
||||||
|
background: $gradient-gold;
|
||||||
|
padding: 2rpx 8rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-percent {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: $text-sub;
|
||||||
|
padding: $spacing-xl;
|
||||||
|
font-size: $font-sm;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
248
components/activity/RewardsPreview.vue
Normal file
248
components/activity/RewardsPreview.vue
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
<template>
|
||||||
|
<view>
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">{{ title }}</text>
|
||||||
|
<text class="section-more" @tap="$emit('view-all')">查看全部</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 分组展示 -->
|
||||||
|
<view v-if="grouped && rewardGroups.length > 0">
|
||||||
|
<view class="prize-level-row" v-for="group in rewardGroups" :key="group.level">
|
||||||
|
<view class="level-header-row">
|
||||||
|
<view class="level-badge" :class="{ 'badge-boss': group.level === 'BOSS' }">
|
||||||
|
{{ isMatchingGroup(group.level) ? group.level : `${group.level}赏` }}
|
||||||
|
</view>
|
||||||
|
<text class="level-prob">总概率 {{ group.totalPercent }}%</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view class="preview-scroll" scroll-x>
|
||||||
|
<view class="preview-item" v-for="(item, idx) in group.rewards" :key="item.id || idx">
|
||||||
|
<view class="prize-tag tag-boss" v-if="item.boss">BOSS</view>
|
||||||
|
<image class="preview-img" :src="item.image" mode="aspectFill" />
|
||||||
|
<view class="preview-name">{{ item.title }}</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 简单列表展示 -->
|
||||||
|
<view v-else-if="rewards.length > 0">
|
||||||
|
<scroll-view class="preview-scroll" scroll-x>
|
||||||
|
<view class="preview-item" v-for="(item, idx) in rewards" :key="idx">
|
||||||
|
<view class="prize-tag" :class="{ 'tag-boss': item.boss }">{{ item.boss ? 'BOSS' : (item.level || '赏') }}</view>
|
||||||
|
<image class="preview-img" :src="item.image" mode="aspectFill" />
|
||||||
|
<view class="preview-name">{{ item.title }}</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-icon">📭</text>
|
||||||
|
<text class="empty-text">{{ emptyText }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { groupRewardsByLevel } from '@/utils/activity'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '奖池配置'
|
||||||
|
},
|
||||||
|
rewards: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
grouped: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
playType: {
|
||||||
|
type: String,
|
||||||
|
default: 'normal'
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: '暂无奖品配置'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['view-all'])
|
||||||
|
|
||||||
|
// 判断是否为对对碰分组(包含"对子"字样)
|
||||||
|
const isMatchingGroup = (level) => {
|
||||||
|
return String(level || '').includes('对子')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewardGroups = computed(() => {
|
||||||
|
if (!props.grouped) return []
|
||||||
|
return groupRewardsByLevel(props.rewards, props.playType)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* ============================================
|
||||||
|
奖池预览 - 与原始设计完全一致
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: $font-md;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-more {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-tertiary;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '>';
|
||||||
|
font-family: monospace;
|
||||||
|
margin-left: 6rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等级分组 */
|
||||||
|
.prize-level-row {
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
background: rgba(0,0,0,0.02);
|
||||||
|
padding: $spacing-md;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: $font-xs;
|
||||||
|
font-weight: 900;
|
||||||
|
color: $text-main;
|
||||||
|
background: #F0F0F0;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-style: italic;
|
||||||
|
border: 1rpx solid rgba(0,0,0,0.05);
|
||||||
|
box-shadow: $shadow-xs;
|
||||||
|
|
||||||
|
&.badge-boss {
|
||||||
|
background: $gradient-gold;
|
||||||
|
color: #78350F;
|
||||||
|
border-color: rgba(217, 119, 6, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-prob {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $brand-primary;
|
||||||
|
font-weight: 800;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览滚动区域 */
|
||||||
|
.preview-scroll {
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
display: inline-block;
|
||||||
|
width: 180rpx;
|
||||||
|
margin-right: $spacing-md;
|
||||||
|
vertical-align: top;
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 180rpx;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
background: $bg-secondary;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
border: 1rpx solid rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-name {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-secondary;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 奖品标签 */
|
||||||
|
.prize-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 10rpx;
|
||||||
|
left: 10rpx;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: $font-xs;
|
||||||
|
padding: 4rpx $spacing-sm;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
z-index: 10;
|
||||||
|
font-weight: 700;
|
||||||
|
backdrop-filter: blur(4rpx);
|
||||||
|
transform: scale(0.9);
|
||||||
|
transform-origin: top left;
|
||||||
|
|
||||||
|
&.tag-boss {
|
||||||
|
background: $gradient-brand;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $spacing-xl;
|
||||||
|
color: $text-sub;
|
||||||
|
min-height: 300rpx; /* 防止切换时布局跳动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 64rpx;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
125
components/activity/RulesPopup.vue
Normal file
125
components/activity/RulesPopup.vue
Normal 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>
|
||||||
|
|
||||||
10
components/activity/index.js
Normal file
10
components/activity/index.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Activity 组件统一导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as ActivityPageLayout } from './ActivityPageLayout.vue'
|
||||||
|
export { default as ActivityHeader } from './ActivityHeader.vue'
|
||||||
|
export { default as ActivityTabs } from './ActivityTabs.vue'
|
||||||
|
export { default as RewardsPreview } from './RewardsPreview.vue'
|
||||||
|
export { default as RewardsPopup } from './RewardsPopup.vue'
|
||||||
|
export { default as RecordsList } from './RecordsList.vue'
|
||||||
87
components/app-tab-bar-toutiao.vue
Normal file
87
components/app-tab-bar-toutiao.vue
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<view class="app-tab-bar-toutiao">
|
||||||
|
<view class="tab-bar-item" @tap="switchTab('pages/cabinet/index')">
|
||||||
|
<image class="tab-icon" :src="selected === 0 ? '/static/tab/box_active.png' : '/static/tab/box.png'" mode="aspectFit"></image>
|
||||||
|
<text class="tab-text" :class="{ active: selected === 0 }">盒柜</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-bar-item" @tap="switchTab('pages/mine/index')">
|
||||||
|
<image class="tab-icon" :src="selected === 1 ? '/static/tab/profile_active.png' : '/static/tab/profile.png'" mode="aspectFit"></image>
|
||||||
|
<text class="tab-text" :class="{ active: selected === 1 }">我的</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selected: 0 // 默认选中"盒柜"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.updateSelected()
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.updateSelected()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateSelected() {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 0) {
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
const route = currentPage.route
|
||||||
|
|
||||||
|
if (route === 'pages/cabinet/index') this.selected = 0
|
||||||
|
else if (route === 'pages/mine/index') this.selected = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
switchTab(url) {
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/' + url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.app-tab-bar-toutiao {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100rpx;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-top: 1rpx solid #E5E5E5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #7A7E83;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #007AFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
105
components/app-tab-bar.vue
Normal file
105
components/app-tab-bar.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<!-- #ifndef MP-TOUTIAO -->
|
||||||
|
<view class="app-tab-bar">
|
||||||
|
<view class="tab-bar-item" @tap="switchTab('pages/index/index')">
|
||||||
|
<image class="tab-icon" :src="selected === 0 ? '/static/tab/home_active.png' : '/static/tab/home.png'" mode="aspectFit"></image>
|
||||||
|
<text class="tab-text" :class="{ active: selected === 0 }">首页</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-bar-item" @tap="switchTab('pages/shop/index')">
|
||||||
|
<image class="tab-icon" :src="selected === 1 ? '/static/tab/shop_active.png' : '/static/tab/shop.png'" mode="aspectFit"></image>
|
||||||
|
<text class="tab-text" :class="{ active: selected === 1 }">商城</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-bar-item" @tap="switchTab('pages/cabinet/index')">
|
||||||
|
<image class="tab-icon" :src="selected === 2 ? '/static/tab/box_active.png' : '/static/tab/box.png'" mode="aspectFit"></image>
|
||||||
|
<text class="tab-text" :class="{ active: selected === 2 }">盒柜</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-bar-item" @tap="switchTab('pages/mine/index')">
|
||||||
|
<image class="tab-icon" :src="selected === 3 ? '/static/tab/profile_active.png' : '/static/tab/profile.png'" mode="aspectFit"></image>
|
||||||
|
<text class="tab-text" :class="{ active: selected === 3 }">我的</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
// #ifndef MP-TOUTIAO
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selected: 0 // 默认选中"首页"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.updateSelected()
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.updateSelected()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateSelected() {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 0) {
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
const route = currentPage.route
|
||||||
|
|
||||||
|
if (route === 'pages/index/index') this.selected = 0
|
||||||
|
else if (route === 'pages/shop/index') this.selected = 1
|
||||||
|
else if (route === 'pages/cabinet/index') this.selected = 2
|
||||||
|
else if (route === 'pages/mine/index') this.selected = 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
switchTab(url) {
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/' + url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* #ifndef MP-TOUTIAO */
|
||||||
|
.app-tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100rpx;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-top: 1rpx solid #E5E5E5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #7A7E83;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #007AFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
</style>
|
||||||
208
docs/代码重构分析/ALIGNMENT_代码冗余分析.md
Normal file
208
docs/代码重构分析/ALIGNMENT_代码冗余分析.md
Normal 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
Normal file
323
docs/代码重构分析/DESIGN_组件化重构.md
Normal 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*
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name" : "app_client",
|
"name" : "app_client",
|
||||||
"appid" : "",
|
"appid" : "__UNI__07C684D",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "1.0.0",
|
"versionName" : "1.0.0",
|
||||||
"versionCode" : "100",
|
"versionCode" : "100",
|
||||||
@ -57,7 +57,11 @@
|
|||||||
"es6" : true,
|
"es6" : true,
|
||||||
"postcss" : true
|
"postcss" : true
|
||||||
},
|
},
|
||||||
"usingComponents" : true
|
"usingComponents" : true,
|
||||||
|
"lazyCodeLoading" : "requiredComponents",
|
||||||
|
"optimization" : {
|
||||||
|
"subPackages" : true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mp-alipay" : {
|
"mp-alipay" : {
|
||||||
"usingComponents" : true
|
"usingComponents" : true
|
||||||
@ -67,7 +71,12 @@
|
|||||||
},
|
},
|
||||||
"mp-toutiao" : {
|
"mp-toutiao" : {
|
||||||
"usingComponents" : true,
|
"usingComponents" : true,
|
||||||
"appid" : "ttf031868c6f33d91001"
|
"appid" : "ttf031868c6f33d91001",
|
||||||
|
"privacy" : {
|
||||||
|
"getPhoneNumber" : {
|
||||||
|
"desc" : "用于登录和账号绑定"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"uniStatistics" : {
|
"uniStatistics" : {
|
||||||
"enable" : false
|
"enable" : false
|
||||||
|
|||||||
2654
pages-activity/activity/duiduipeng/index.vue
Normal file
2654
pages-activity/activity/duiduipeng/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -94,10 +94,10 @@ function onActivityTap(a) {
|
|||||||
let path = ''
|
let path = ''
|
||||||
|
|
||||||
// Navigate to DETAIL, not list
|
// Navigate to DETAIL, not list
|
||||||
if (name.includes('一番赏')) path = '/pages/activity/yifanshang/index'
|
if (name.includes('一番赏')) path = '/pages-activity/activity/yifanshang/index'
|
||||||
else if (name.includes('无限赏')) path = '/pages/activity/wuxianshang/index'
|
else if (name.includes('无限赏')) path = '/pages-activity/activity/wuxianshang/index'
|
||||||
else if (name.includes('对对碰')) path = '/pages/activity/duiduipeng/index'
|
else if (name.includes('对对碰')) path = '/pages-activity/activity/duiduipeng/index'
|
||||||
else if (name.includes('爬塔')) path = '/pages/activity/pata/index'
|
else if (name.includes('爬塔')) path = '/pages-activity/activity/pata/index'
|
||||||
|
|
||||||
if (path && id) {
|
if (path && id) {
|
||||||
uni.navigateTo({ url: `${path}?id=${id}` })
|
uni.navigateTo({ url: `${path}?id=${id}` })
|
||||||
@ -122,17 +122,27 @@ import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
|||||||
|
|
||||||
onShareAppMessage(() => {
|
onShareAppMessage(() => {
|
||||||
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
|
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
|
||||||
|
// #ifdef MP-TOUTIAO
|
||||||
|
// 抖音平台分享到商城页面
|
||||||
return {
|
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}`,
|
path: `/pages/index/index?invite_code=${inviteCode}`,
|
||||||
imageUrl: '/static/logo.png'
|
imageUrl: '/static/logo.png'
|
||||||
}
|
}
|
||||||
|
// #endif
|
||||||
})
|
})
|
||||||
|
|
||||||
onShareTimeline(() => {
|
onShareTimeline(() => {
|
||||||
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
|
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
|
||||||
return {
|
return {
|
||||||
title: `${title.value || '精彩活动'} - 奇盒潮玩`,
|
title: `${title.value || '精彩活动'} - 柯大鸭潮玩`,
|
||||||
query: `invite_code=${inviteCode}`,
|
query: `invite_code=${inviteCode}`,
|
||||||
imageUrl: '/static/logo.png'
|
imageUrl: '/static/logo.png'
|
||||||
}
|
}
|
||||||
398
pages-activity/activity/pata/index.vue
Normal file
398
pages-activity/activity/pata/index.vue
Normal 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>
|
||||||
688
pages-activity/activity/wuxianshang/index.vue
Normal file
688
pages-activity/activity/wuxianshang/index.vue
Normal file
@ -0,0 +1,688 @@
|
|||||||
|
<template>
|
||||||
|
<ActivityPageLayout :cover-url="coverUrl" bottom-padding="220rpx">
|
||||||
|
<template #header>
|
||||||
|
<ActivityHeader
|
||||||
|
:title="detail.name || detail.title || '无限赏'"
|
||||||
|
:price="detail.price_draw"
|
||||||
|
price-unit="/发"
|
||||||
|
:cover-url="coverUrl"
|
||||||
|
:tags="['公开透明', '可验证']"
|
||||||
|
@show-rules="showRules"
|
||||||
|
@go-cabinet="goCabinet"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<ActivityTabs v-model="tabActive" :stagger="1">
|
||||||
|
<template #pool>
|
||||||
|
<RewardsPreview
|
||||||
|
title="奖池配置"
|
||||||
|
:rewards="currentIssueRewards"
|
||||||
|
:grouped="true"
|
||||||
|
@view-all="rewardsVisible = true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #records>
|
||||||
|
<RecordsList :records="winRecords" />
|
||||||
|
</template>
|
||||||
|
</ActivityTabs>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<!-- 底部多档位抽赏按钮 -->
|
||||||
|
<view class="bottom-actions">
|
||||||
|
<view class="tier-btn" @tap="openPayment(1)">
|
||||||
|
<text class="tier-price">¥{{ (pricePerDraw * 1).toFixed(2) }}</text>
|
||||||
|
<text class="tier-label">抽1发</text>
|
||||||
|
</view>
|
||||||
|
<view class="tier-btn" @tap="openPayment(3)">
|
||||||
|
<text class="tier-price">¥{{ (pricePerDraw * 3).toFixed(2) }}</text>
|
||||||
|
<text class="tier-label">抽3发</text>
|
||||||
|
</view>
|
||||||
|
<view class="tier-btn" @tap="openPayment(5)">
|
||||||
|
<text class="tier-price">¥{{ (pricePerDraw * 5).toFixed(2) }}</text>
|
||||||
|
<text class="tier-label">抽5发</text>
|
||||||
|
</view>
|
||||||
|
<view class="tier-btn tier-hot" @tap="openPayment(10)">
|
||||||
|
<text class="tier-price">¥{{ (pricePerDraw * 10).toFixed(2) }}</text>
|
||||||
|
<text class="tier-label">抽10发</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #modals>
|
||||||
|
<RewardsPopup
|
||||||
|
v-model:visible="rewardsVisible"
|
||||||
|
:title="`${currentIssueTitle} · 奖池与概率`"
|
||||||
|
:reward-groups="rewardGroups"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LotteryResultPopup
|
||||||
|
v-model:visible="showResultPopup"
|
||||||
|
:results="drawResults"
|
||||||
|
:show-retry-button="lastDrawUsedGamePass"
|
||||||
|
@close="onResultClose"
|
||||||
|
@retry="onRetryDraw"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaymentPopup
|
||||||
|
v-model:visible="paymentVisible"
|
||||||
|
:amount="paymentAmount"
|
||||||
|
:coupons="coupons"
|
||||||
|
:gamePasses="gamePasses"
|
||||||
|
:propCards="propCards"
|
||||||
|
@confirm="onPaymentConfirm"
|
||||||
|
/>
|
||||||
|
<RulesPopup
|
||||||
|
v-model:visible="rulesVisible"
|
||||||
|
:content="detail.gameplay_intro"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CabinetPreviewPopup
|
||||||
|
v-model:visible="cabinetVisible"
|
||||||
|
:activity-id="activityId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CabinetPreviewPopup
|
||||||
|
v-model:visible="cabinetVisible"
|
||||||
|
:activity-id="activityId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 开奖加载弹窗 -->
|
||||||
|
<DrawLoadingPopup
|
||||||
|
:visible="showDrawLoading"
|
||||||
|
:progress="drawProgress"
|
||||||
|
:total="drawTotal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GamePassPurchasePopup
|
||||||
|
v-model:visible="purchasePopupVisible"
|
||||||
|
:activity-id="activityId"
|
||||||
|
@success="onPurchaseSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 悬浮次数卡入口 -->
|
||||||
|
<view v-if="gamePassRemaining > 0 || true" class="game-pass-float" @tap="openPurchasePopup">
|
||||||
|
<view class="badge-content">
|
||||||
|
<text class="badge-icon">🎮</text>
|
||||||
|
<text class="badge-text" v-if="gamePassRemaining > 0">{{ gamePassRemaining }}</text>
|
||||||
|
<text class="badge-text" v-else>购买</text>
|
||||||
|
</view>
|
||||||
|
<view class="badge-label">使用次数</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</ActivityPageLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
// 公共组件 - uni-app需要直接导入.vue文件
|
||||||
|
import ActivityPageLayout from '@/components/activity/ActivityPageLayout.vue'
|
||||||
|
import ActivityHeader from '@/components/activity/ActivityHeader.vue'
|
||||||
|
import ActivityTabs from '@/components/activity/ActivityTabs.vue'
|
||||||
|
import RewardsPreview from '@/components/activity/RewardsPreview.vue'
|
||||||
|
import RewardsPopup from '@/components/activity/RewardsPopup.vue'
|
||||||
|
import RecordsList from '@/components/activity/RecordsList.vue'
|
||||||
|
import RulesPopup from '@/components/activity/RulesPopup.vue'
|
||||||
|
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
|
||||||
|
import LotteryResultPopup from '@/components/activity/LotteryResultPopup.vue'
|
||||||
|
import DrawLoadingPopup from '@/components/activity/DrawLoadingPopup.vue'
|
||||||
|
import PaymentPopup from '@/components/PaymentPopup.vue'
|
||||||
|
import GamePassPurchasePopup from '@/components/GamePassPurchasePopup.vue'
|
||||||
|
import { getGamePasses } from '@/api/appUser'
|
||||||
|
// Composables
|
||||||
|
import { useActivity, useIssues, useRewards, useRecords } from '../../composables'
|
||||||
|
// API
|
||||||
|
import { joinLottery, createWechatOrder, getLotteryResult, getItemCards, getUserCoupons } from '@/api/appUser'
|
||||||
|
|
||||||
|
// ============ 使用Composables ============
|
||||||
|
const activityId = ref('')
|
||||||
|
|
||||||
|
const {
|
||||||
|
detail,
|
||||||
|
coverUrl,
|
||||||
|
fetchDetail,
|
||||||
|
setNavigationTitle
|
||||||
|
} = useActivity(activityId)
|
||||||
|
|
||||||
|
const pricePerDraw = computed(() => Number(detail.value?.price_draw || 0) / 100)
|
||||||
|
|
||||||
|
const {
|
||||||
|
issues,
|
||||||
|
currentIssueId,
|
||||||
|
currentIssueTitle,
|
||||||
|
fetchIssues
|
||||||
|
} = useIssues(activityId)
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentIssueRewards,
|
||||||
|
rewardGroups,
|
||||||
|
fetchRewardsForIssues
|
||||||
|
} = useRewards(activityId, currentIssueId)
|
||||||
|
|
||||||
|
const {
|
||||||
|
winRecords,
|
||||||
|
fetchWinRecords
|
||||||
|
} = useRecords()
|
||||||
|
|
||||||
|
// ============ 本地状态 ============
|
||||||
|
const tabActive = ref('pool')
|
||||||
|
const rewardsVisible = ref(false)
|
||||||
|
const rulesVisible = ref(false)
|
||||||
|
const cabinetVisible = ref(false)
|
||||||
|
const showResultPopup = ref(false)
|
||||||
|
const drawResults = ref([])
|
||||||
|
const drawLoading = ref(false)
|
||||||
|
const showDrawLoading = ref(false)
|
||||||
|
const drawProgress = ref(0)
|
||||||
|
const drawTotal = ref(1)
|
||||||
|
const lastDrawUsedGamePass = ref(false) // 记录最后一次抽奖是否使用了次数卡
|
||||||
|
const lastDrawCount = ref(1) // 记录最后一次抽奖的数量
|
||||||
|
|
||||||
|
// 支付相关
|
||||||
|
const paymentVisible = ref(false)
|
||||||
|
const paymentAmount = ref('0.00')
|
||||||
|
const coupons = ref([])
|
||||||
|
const propCards = ref([])
|
||||||
|
const pendingCount = ref(1)
|
||||||
|
const selectedCoupon = ref(null)
|
||||||
|
const selectedCard = ref(null)
|
||||||
|
const useGamePassFlag = ref(false)
|
||||||
|
|
||||||
|
// ============ 次数卡逻辑 ============
|
||||||
|
const gamePasses = ref(null)
|
||||||
|
const gamePassRemaining = computed(() => gamePasses.value?.total_remaining || 0)
|
||||||
|
const purchasePopupVisible = ref(false)
|
||||||
|
|
||||||
|
async function fetchPasses() {
|
||||||
|
if (!activityId.value) return
|
||||||
|
try {
|
||||||
|
const res = await getGamePasses(activityId.value)
|
||||||
|
gamePasses.value = res || null
|
||||||
|
} catch (e) {
|
||||||
|
gamePasses.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPurchasePopup() {
|
||||||
|
purchasePopupVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPurchaseSuccess() {
|
||||||
|
fetchPasses()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 业务方法 ============
|
||||||
|
function showRules() {
|
||||||
|
rulesVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function goCabinet() {
|
||||||
|
cabinetVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResultClose() {
|
||||||
|
showResultPopup.value = false
|
||||||
|
drawResults.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRetryDraw() {
|
||||||
|
// 关闭结果弹窗
|
||||||
|
showResultPopup.value = false
|
||||||
|
drawResults.value = []
|
||||||
|
|
||||||
|
// 检查是否还有剩余次数卡
|
||||||
|
if (gamePassRemaining.value > 0) {
|
||||||
|
// 使用次数卡直接抽奖
|
||||||
|
useGamePassFlag.value = true
|
||||||
|
selectedCoupon.value = null
|
||||||
|
selectedCard.value = null
|
||||||
|
onMachineDraw(lastDrawCount.value)
|
||||||
|
} else {
|
||||||
|
// 次数卡已用完,打开支付弹窗
|
||||||
|
openPayment(lastDrawCount.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPayment(count) {
|
||||||
|
const times = Math.max(1, Number(count || 1))
|
||||||
|
pendingCount.value = times
|
||||||
|
paymentAmount.value = (pricePerDraw.value * times).toFixed(2)
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
// 使用统一的手机号绑定检查
|
||||||
|
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
|
||||||
|
if (!token || !hasPhoneBound) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '请先登录并绑定手机号',
|
||||||
|
confirmText: '去登录',
|
||||||
|
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
paymentVisible.value = true
|
||||||
|
// 并行获取道具卡和优惠券
|
||||||
|
Promise.all([fetchPropCards(), fetchCoupons()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPaymentConfirm(data) {
|
||||||
|
selectedCoupon.value = data?.coupon || null
|
||||||
|
selectedCard.value = data?.card || null
|
||||||
|
useGamePassFlag.value = data?.useGamePass || false
|
||||||
|
paymentVisible.value = false
|
||||||
|
await onMachineDraw(pendingCount.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPropCards() {
|
||||||
|
const user_id = uni.getStorageSync('user_id')
|
||||||
|
if (!user_id) return
|
||||||
|
try {
|
||||||
|
const res = await getItemCards(user_id)
|
||||||
|
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
|
||||||
|
|
||||||
|
// Group identical cards by name
|
||||||
|
const groupedMap = new Map()
|
||||||
|
list.forEach((i, idx) => {
|
||||||
|
const name = i.name ?? i.title ?? '道具卡'
|
||||||
|
if (!groupedMap.has(name)) {
|
||||||
|
groupedMap.set(name, {
|
||||||
|
id: i.id ?? i.card_id ?? String(idx),
|
||||||
|
name: name,
|
||||||
|
count: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
groupedMap.get(name).count++
|
||||||
|
})
|
||||||
|
|
||||||
|
propCards.value = Array.from(groupedMap.values())
|
||||||
|
} catch (e) {
|
||||||
|
propCards.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCoupons() {
|
||||||
|
const user_id = uni.getStorageSync('user_id')
|
||||||
|
if (!user_id) return
|
||||||
|
try {
|
||||||
|
const res = await getUserCoupons(user_id, 0, 1, 100)
|
||||||
|
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
|
||||||
|
coupons.value = list.map((i, idx) => {
|
||||||
|
const amountCents = i.remaining ?? i.amount ?? i.value ?? 0
|
||||||
|
const amt = isNaN(amountCents) ? 0 : (Number(amountCents) / 100)
|
||||||
|
return {
|
||||||
|
id: i.id ?? i.coupon_id ?? String(idx),
|
||||||
|
name: i.name ?? i.title ?? '优惠券',
|
||||||
|
amount: amt.toFixed(2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
coupons.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractResultList(resultRes) {
|
||||||
|
const root = resultRes?.data ?? resultRes?.result ?? resultRes
|
||||||
|
if (!root) return []
|
||||||
|
// Backend now returns results array with all draw logs including doubled
|
||||||
|
if (resultRes?.results && Array.isArray(resultRes.results) && resultRes.results.length > 0) {
|
||||||
|
return resultRes.results
|
||||||
|
}
|
||||||
|
return root.results || root.list || root.items || root.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapResultsToFlipItems(resultRes, poolRewards) {
|
||||||
|
const list = extractResultList(resultRes)
|
||||||
|
const poolArr = Array.isArray(poolRewards) ? poolRewards : []
|
||||||
|
const lookup = new Map()
|
||||||
|
poolArr.forEach(it => {
|
||||||
|
const id = it?.id ?? it?.reward_id ?? it?.product_id
|
||||||
|
if (id !== undefined) lookup.set(Number(id), it)
|
||||||
|
})
|
||||||
|
|
||||||
|
return list.filter(Boolean).map(d => {
|
||||||
|
const rewardId = d.reward_id ?? d.rewardId ?? d.product_id ?? d.productId ?? d.id
|
||||||
|
const rewardName = String(d.reward_name ?? d.rewardName ?? d.product_name ?? d.productName ?? d.title ?? d.name ?? '')
|
||||||
|
const fromId = Number.isFinite(Number(rewardId)) ? lookup.get(Number(rewardId)) : null
|
||||||
|
const fromName = !fromId && rewardName ? poolArr.find(x => x?.title === rewardName) : null
|
||||||
|
const it = fromId || fromName || null
|
||||||
|
return {
|
||||||
|
reward_id: rewardId, // 添加reward_id用于正确分组
|
||||||
|
title: rewardName || it?.title || '奖励',
|
||||||
|
image: d.image || it?.image || d.img || d.pic || d.product_image || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMachineDraw(count) {
|
||||||
|
const aid = activityId.value
|
||||||
|
const iid = currentIssueId.value
|
||||||
|
if (!aid || !iid) {
|
||||||
|
uni.showToast({ title: '期数未选择', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
// 使用统一的手机号绑定检查
|
||||||
|
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
|
||||||
|
if (!token || !hasPhoneBound) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '请先登录并绑定手机号',
|
||||||
|
confirmText: '去登录',
|
||||||
|
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const openid = uni.getStorageSync('openid')
|
||||||
|
if (!openid) {
|
||||||
|
uni.showToast({ title: '缺少OpenID,请重新登录', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
drawLoading.value = true
|
||||||
|
try {
|
||||||
|
const times = Math.max(1, Number(count || 1))
|
||||||
|
const joinRes = await joinLottery({
|
||||||
|
activity_id: Number(aid),
|
||||||
|
issue_id: Number(iid),
|
||||||
|
channel: 'miniapp',
|
||||||
|
count: times,
|
||||||
|
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0,
|
||||||
|
item_card_id: selectedCard.value?.id ? Number(selectedCard.value.id) : 0,
|
||||||
|
use_game_pass: useGamePassFlag.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 支付成功刷新次数卡
|
||||||
|
if (useGamePassFlag.value) {
|
||||||
|
fetchPasses()
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderNo = joinRes?.order_no || joinRes?.data?.order_no || joinRes?.result?.order_no
|
||||||
|
if (!orderNo) throw new Error('未获取到订单号')
|
||||||
|
|
||||||
|
// Check if order is already paid (e.g. via Game Pass or Points)
|
||||||
|
const isPaid = (joinRes?.status === 2) || (joinRes?.actual_amount <= 0)
|
||||||
|
|
||||||
|
if (!isPaid) {
|
||||||
|
const payRes = await createWechatOrder({ openid, order_no: orderNo })
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
uni.requestPayment({
|
||||||
|
provider: 'wxpay',
|
||||||
|
timeStamp: payRes.timeStamp || payRes.timestamp,
|
||||||
|
nonceStr: payRes.nonceStr || payRes.noncestr,
|
||||||
|
package: payRes.package,
|
||||||
|
signType: payRes.signType || 'MD5',
|
||||||
|
paySign: payRes.paySign,
|
||||||
|
success: resolve,
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支付成功后立即显示开奖加载弹窗
|
||||||
|
drawTotal.value = times
|
||||||
|
drawProgress.value = 0
|
||||||
|
showDrawLoading.value = true
|
||||||
|
|
||||||
|
// 轮询等待开奖完成
|
||||||
|
let resultRes = await getLotteryResult(orderNo)
|
||||||
|
let pollCount = 0
|
||||||
|
const maxPolls = 15 // 最多轮询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)
|
||||||
|
}
|
||||||
|
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
Normal file
1160
pages-activity/activity/yifanshang/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
8
pages-activity/composables/index.js
Normal file
8
pages-activity/composables/index.js
Normal 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
Normal file
72
pages-activity/composables/useActivity.js
Normal 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
Normal file
97
pages-activity/composables/useIssues.js
Normal 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
Normal file
109
pages-activity/composables/useRecords.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 购买记录管理 Composable
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getIssueDrawLogs } from '@/api/appUser'
|
||||||
|
import { levelToAlpha } from '@/utils/activity'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买记录管理
|
||||||
|
*/
|
||||||
|
export function useRecords() {
|
||||||
|
const winRecords = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取购买记录
|
||||||
|
* @param {string} activityId - 活动ID
|
||||||
|
* @param {string} issueId - 期ID
|
||||||
|
*/
|
||||||
|
async function fetchWinRecords(activityId, issueId) {
|
||||||
|
if (!activityId || !issueId) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getIssueDrawLogs(activityId, issueId)
|
||||||
|
const list = (res && res.list) || (Array.isArray(res) ? res : [])
|
||||||
|
|
||||||
|
// 直接使用原始记录列表,不进行聚合
|
||||||
|
// 映射字段以符合 RecordsList 组件的展示需求
|
||||||
|
winRecords.value = list.map(it => ({
|
||||||
|
id: it.id,
|
||||||
|
title: it.reward_name || it.title || it.name || '-', // 奖品名称
|
||||||
|
image: it.reward_image || it.image || '', // 奖品图片
|
||||||
|
count: 1, // 单个记录数量为1
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
user_id: it.user_id,
|
||||||
|
user_name: it.user_name || '匿名用户',
|
||||||
|
avatar: cleanAvatar(it.avatar), // 清理 avatar 数据
|
||||||
|
|
||||||
|
// 时间信息
|
||||||
|
created_at: it.created_at,
|
||||||
|
|
||||||
|
// 其他元数据
|
||||||
|
is_winner: it.is_winner,
|
||||||
|
level: it.level,
|
||||||
|
level_name: getLevelName(it.level)
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('fetchWinRecords error', e)
|
||||||
|
winRecords.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelName(level) {
|
||||||
|
if (!level) return ''
|
||||||
|
const alpha = levelToAlpha(level)
|
||||||
|
return alpha + '赏'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理和验证 avatar 数据
|
||||||
|
* @param {string} avatar - 原始 avatar 数据(可能是 base64 或 URL)
|
||||||
|
* @returns {string} - 清理后的 avatar 数据
|
||||||
|
*/
|
||||||
|
function cleanAvatar(avatar) {
|
||||||
|
if (!avatar) return ''
|
||||||
|
|
||||||
|
// 如果是 base64 格式,确保格式正确
|
||||||
|
const avatarStr = String(avatar).trim()
|
||||||
|
|
||||||
|
// 检查是否已经是 data:image 格式
|
||||||
|
if (avatarStr.startsWith('data:image/')) {
|
||||||
|
return avatarStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是 http(s) URL,直接返回
|
||||||
|
if (avatarStr.startsWith('http://') || avatarStr.startsWith('https://')) {
|
||||||
|
return avatarStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是相对路径,直接返回
|
||||||
|
if (avatarStr.startsWith('/')) {
|
||||||
|
return avatarStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他情况,可能是不完整的 base64,尝试修复
|
||||||
|
// 如果不包含 data:image 前缀,添加默认的 png 前缀
|
||||||
|
if (avatarStr.match(/^[A-Za-z0-9+/=]+$/)) {
|
||||||
|
// 看起来像 base64 编码
|
||||||
|
return `data:image/png;base64,${avatarStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return avatarStr
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRecords() {
|
||||||
|
winRecords.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
winRecords,
|
||||||
|
loading,
|
||||||
|
fetchWinRecords,
|
||||||
|
clearRecords
|
||||||
|
}
|
||||||
|
}
|
||||||
86
pages-activity/composables/useRewards.js
Normal file
86
pages-activity/composables/useRewards.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
326
pages-game/game/minesweeper/index.vue
Normal file
326
pages-game/game/minesweeper/index.vue
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<!-- 背景装饰 -->
|
||||||
|
<view class="bg-decoration"></view>
|
||||||
|
|
||||||
|
<!-- 头部 -->
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">动物扫雷大作战</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 主内容 -->
|
||||||
|
<view class="content">
|
||||||
|
<!-- 游戏图标 -->
|
||||||
|
<view class="game-icon-box fadeInUp">
|
||||||
|
<text class="game-icon">💣</text>
|
||||||
|
<view class="game-glow"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 游戏介绍 -->
|
||||||
|
<view class="glass-card intro-card fadeInUp" style="animation-delay: 0.1s;">
|
||||||
|
<text class="intro-title">多人对战扫雷</text>
|
||||||
|
<text class="intro-desc">快来挑战,获胜领取礼品!</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 资格显示 -->
|
||||||
|
<view class="glass-card ticket-card fadeInUp" v-if="!loading" style="animation-delay: 0.2s;">
|
||||||
|
<view class="ticket-row">
|
||||||
|
<text class="ticket-label">我的活动资格</text>
|
||||||
|
<view class="ticket-count-box">
|
||||||
|
<text class="ticket-count">{{ ticketCount }}</text>
|
||||||
|
<text class="ticket-unit">次</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="divider"></view>
|
||||||
|
<text class="ticket-tip">{{ ticketCount > 0 ? '每次进入消耗1次资格' : '完成任务或抖店购买指定链接可获赠活动资格哦~' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<view v-else class="loading-box">
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部按钮 -->
|
||||||
|
<view class="footer">
|
||||||
|
<view
|
||||||
|
class="btn-primary"
|
||||||
|
:class="{ disabled: ticketCount <= 0 || entering }"
|
||||||
|
@tap="enterGame"
|
||||||
|
>
|
||||||
|
<text class="enter-btn-text">{{ entering ? '正在进入...' : (ticketCount > 0 ? '立即开局' : '资格不足') }}</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="btn-secondary"
|
||||||
|
style="margin-top: 24rpx; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 55rpx; height: 110rpx; display: flex; align-items: center; justify-content: center;"
|
||||||
|
@tap="goRoomList"
|
||||||
|
>
|
||||||
|
<text style="color: #94a3b8; font-size: 32rpx; font-weight: 600;">📡 对战列表 / 围观</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { authRequest } from '../../../utils/request.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
ticketCount: 0,
|
||||||
|
entering: false,
|
||||||
|
gameCode: 'minesweeper'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.loadTickets()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
|
||||||
|
async loadTickets() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const userInfo = uni.getStorageSync('user_info') || {}
|
||||||
|
const userId = userInfo.id || userInfo.user_id
|
||||||
|
if (!userId) {
|
||||||
|
this.ticketCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await authRequest({
|
||||||
|
url: `/api/app/users/${userId}/game_tickets`
|
||||||
|
})
|
||||||
|
this.ticketCount = res[this.gameCode] || 0
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载游戏资格失败', e)
|
||||||
|
this.ticketCount = 0
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async enterGame() {
|
||||||
|
if (this.ticketCount <= 0 || this.entering) return
|
||||||
|
|
||||||
|
this.entering = true
|
||||||
|
try {
|
||||||
|
const res = await authRequest({
|
||||||
|
url: '/api/app/games/enter',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
game_code: this.gameCode
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const gameToken = encodeURIComponent(res.game_token)
|
||||||
|
const nakamaServer = encodeURIComponent(res.nakama_server)
|
||||||
|
const nakamaKey = encodeURIComponent(res.nakama_key)
|
||||||
|
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-game/game/minesweeper/play?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({
|
||||||
|
title: e.message || '进入游戏失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.entering = false
|
||||||
|
this.loadTickets()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async goRoomList() {
|
||||||
|
// 获取配置以拿到 nakama 参数,虽然 room-list 也会尝试连接,但通过 URL 传参更稳妥
|
||||||
|
try {
|
||||||
|
const res = await authRequest({
|
||||||
|
url: '/api/app/games/enter',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
game_code: this.gameCode
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const gameToken = encodeURIComponent(res.game_token)
|
||||||
|
const nakamaServer = encodeURIComponent(res.nakama_server)
|
||||||
|
const nakamaKey = encodeURIComponent(res.nakama_key)
|
||||||
|
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-game/game/minesweeper/room-list?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '无法获取对战列表', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '@/uni.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: $bg-page;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
padding-top: calc(80rpx + env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $text-main;
|
||||||
|
margin-right: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40rpx 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-icon-box {
|
||||||
|
position: relative;
|
||||||
|
margin: 60rpx 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-icon {
|
||||||
|
font-size: 180rpx;
|
||||||
|
animation: float 4s ease-in-out infinite;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-glow {
|
||||||
|
position: absolute;
|
||||||
|
width: 280rpx;
|
||||||
|
height: 280rpx;
|
||||||
|
background: radial-gradient(circle, rgba($brand-primary, 0.25) 0%, transparent 70%);
|
||||||
|
filter: blur(20rpx);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-card {
|
||||||
|
width: 100%;
|
||||||
|
padding: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-title {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: $brand-primary;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-desc {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-card {
|
||||||
|
width: 100%;
|
||||||
|
padding: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-label {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: $text-main;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-count-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-count {
|
||||||
|
font-size: 64rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: $brand-primary;
|
||||||
|
margin-right: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-unit {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
margin: 32rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-tip {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box {
|
||||||
|
padding: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 40rpx;
|
||||||
|
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
height: 110rpx;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: $text-disabled;
|
||||||
|
box-shadow: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.enter-btn-text {
|
||||||
|
font-size: 36rpx;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations from App.vue are global, but we use local ones if needed */
|
||||||
|
.fadeInUp {
|
||||||
|
animation: fadeInUp 0.6s ease-out both;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1695
pages-game/game/minesweeper/play.scss
Normal file
1695
pages-game/game/minesweeper/play.scss
Normal file
File diff suppressed because it is too large
Load Diff
1128
pages-game/game/minesweeper/play.vue
Normal file
1128
pages-game/game/minesweeper/play.vue
Normal file
File diff suppressed because it is too large
Load Diff
348
pages-game/game/minesweeper/room-list.vue
Normal file
348
pages-game/game/minesweeper/room-list.vue
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="bg-decoration"></view>
|
||||||
|
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">实时对战信号</text>
|
||||||
|
<view class="refresh-text-btn" :class="{ loading: loading }" @tap="loadRooms">
|
||||||
|
{{ loading ? '同步中...' : `刷新信号 (${countdown}s)` }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y class="content" @refresherrefresh="loadRooms" :refresher-enabled="true" :refresher-triggered="isRefreshing">
|
||||||
|
<view v-if="rooms.length > 0" class="room-list">
|
||||||
|
<view v-for="room in rooms" :key="room.match_id" class="room-card glass-card fadeInUp">
|
||||||
|
<view class="room-main">
|
||||||
|
<view class="room-info">
|
||||||
|
<view class="room-header">
|
||||||
|
<text class="room-id">房间 #{{ room.match_id.split('.')[0].substring(0, 6) }}</text>
|
||||||
|
<view class="status-badge" :class="room.started ? 'started' : 'waiting'">
|
||||||
|
{{ room.started ? '进行中' : '等待中' }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="room-stats">
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-icon">👥</text>
|
||||||
|
<text class="stat-text">{{ room.player_count }}/{{ room.max_players }} 玩家</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-icon">📡</text>
|
||||||
|
<text class="stat-text">延迟: {{ Math.floor(Math.random() * 50) + 20 }}ms</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="room-actions">
|
||||||
|
<view v-if="!room.started && room.player_count < room.max_players" class="btn-action join" @tap="joinRoom(room)">
|
||||||
|
<text class="action-text">加入</text>
|
||||||
|
</view>
|
||||||
|
<view class="btn-action watch" @tap="watchRoom(room)">
|
||||||
|
<text class="action-text">围观</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="!loading" class="empty-box">
|
||||||
|
<view class="empty-icon">🛰️</view>
|
||||||
|
<text class="empty-text">未监测到活跃战局</text>
|
||||||
|
<view class="btn-primary start-new" @tap="goBack">去发起匹配</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { nakamaManager } from '../../../utils/nakamaManager.js';
|
||||||
|
import { authRequest } from '../../../utils/request.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
rooms: [],
|
||||||
|
loading: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
gameToken: '',
|
||||||
|
nakamaServer: '',
|
||||||
|
nakamaKey: '',
|
||||||
|
refreshInterval: null,
|
||||||
|
countdownInterval: null,
|
||||||
|
countdown: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.gameToken = options.game_token;
|
||||||
|
this.nakamaServer = decodeURIComponent(options.nakama_server || '');
|
||||||
|
this.nakamaKey = decodeURIComponent(options.nakama_key || '');
|
||||||
|
this.initAndLoad();
|
||||||
|
|
||||||
|
// 启动倒计时定时器,每秒更新一次
|
||||||
|
this.countdownInterval = setInterval(() => {
|
||||||
|
this.countdown--;
|
||||||
|
if (this.countdown <= 0) {
|
||||||
|
this.countdown = 5;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 启动自动刷新定时器,每5秒刷新一次
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
this.loadRooms();
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
// 清理定时器
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval);
|
||||||
|
this.refreshInterval = null;
|
||||||
|
}
|
||||||
|
if (this.countdownInterval) {
|
||||||
|
clearInterval(this.countdownInterval);
|
||||||
|
this.countdownInterval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async initAndLoad() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
if (!nakamaManager.isConnected) {
|
||||||
|
nakamaManager.initClient(this.nakamaServer || 'wss://game.1024tool.vip', this.nakamaKey || 'defaultkey');
|
||||||
|
await nakamaManager.authenticateWithGameToken(this.gameToken);
|
||||||
|
}
|
||||||
|
await this.loadRooms();
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '连接通讯中心失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadRooms() {
|
||||||
|
this.isRefreshing = true;
|
||||||
|
try {
|
||||||
|
const res = await nakamaManager.rpc('list_matches', {});
|
||||||
|
this.rooms = res || [];
|
||||||
|
// 重置倒计时
|
||||||
|
this.countdown = 5;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load rooms', e);
|
||||||
|
} finally {
|
||||||
|
this.isRefreshing = false;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goBack() {
|
||||||
|
uni.navigateBack();
|
||||||
|
},
|
||||||
|
joinRoom(room) {
|
||||||
|
// 通过 MatchID 传参给 play.vue
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-game/game/minesweeper/play?match_id=${room.match_id}&game_token=${encodeURIComponent(this.gameToken)}&nakama_server=${encodeURIComponent(this.nakamaServer)}&nakama_key=${encodeURIComponent(this.nakamaKey)}`
|
||||||
|
});
|
||||||
|
},
|
||||||
|
watchRoom(room) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-game/game/minesweeper/play?match_id=${room.match_id}&is_spectator=1&game_token=${encodeURIComponent(this.gameToken)}&nakama_server=${encodeURIComponent(this.nakamaServer)}&nakama_key=${encodeURIComponent(this.nakamaKey)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/uni.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 400rpx;
|
||||||
|
background: radial-gradient(circle at 50% 0%, rgba(59, 130, 246, 0.15) 0%, transparent 70%);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 100rpx 40rpx 40rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-text-btn {
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 30rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-list {
|
||||||
|
padding-bottom: 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-card {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-id {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&.waiting {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.started {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 24rpx;
|
||||||
|
margin-right: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
padding: 16rpx 32rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&.join {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.watch {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #f8fafc;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 120rpx;
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-new {
|
||||||
|
width: 320rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20rpx);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fadeInUp {
|
||||||
|
animation: fadeInUp 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
68
pages-game/game/webview.vue
Normal file
68
pages-game/game/webview.vue
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<web-view :src="url" @message="onMessage"></web-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
|
const url = ref('')
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
if (options.url) {
|
||||||
|
let targetUrl = decodeURIComponent(options.url)
|
||||||
|
|
||||||
|
// Append auth info if not present
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
const uid = uni.getStorageSync('user_id')
|
||||||
|
|
||||||
|
const hasQuery = targetUrl.includes('?')
|
||||||
|
const separator = hasQuery ? '&' : '?'
|
||||||
|
|
||||||
|
// Append standard auth params for the game to consume
|
||||||
|
if (token) targetUrl += `${separator}token=${encodeURIComponent(token)}`
|
||||||
|
if (uid) targetUrl += `&uid=${encodeURIComponent(uid)}`
|
||||||
|
// Append ticket if provided
|
||||||
|
if (options.ticket) targetUrl += `&ticket=${encodeURIComponent(options.ticket)}`
|
||||||
|
|
||||||
|
console.log('Opening Game WebView:', targetUrl)
|
||||||
|
url.value = targetUrl
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '游戏地址无效', icon: 'none' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 1500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onMessage(e) {
|
||||||
|
console.log('Message from Game:', e.detail)
|
||||||
|
const data = e.detail.data || []
|
||||||
|
|
||||||
|
// Handle specific messages
|
||||||
|
data.forEach(msg => {
|
||||||
|
if (msg.action === 'close') {
|
||||||
|
uni.navigateBack()
|
||||||
|
} else if (msg.action === 'playAgain') {
|
||||||
|
// 再来一局: 返回上一页,上一页会自动刷新重新获取token进入游戏
|
||||||
|
console.log('PlayAgain: 返回游戏入口页面')
|
||||||
|
uni.navigateBack({
|
||||||
|
delta: 1,
|
||||||
|
success: () => {
|
||||||
|
// 可选: 发送事件通知上一页刷新
|
||||||
|
uni.$emit('refreshGame')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (msg.action === 'game_over') {
|
||||||
|
// Optional: Refresh user balance or state
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
379
pages-shop/shop/detail.vue
Normal file
379
pages-shop/shop/detail.vue
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="bg-decoration"></view>
|
||||||
|
<view class="loading" v-if="loading">加载中...</view>
|
||||||
|
<view v-else-if="isOutOfStock" class="empty">商品库存不足,由于市场价格存在波动,请联系客服核实价格和补充库存</view>
|
||||||
|
<view v-else-if="detail.id" class="detail-wrap">
|
||||||
|
<image v-if="detail.main_image" class="main-image" :src="detail.main_image" mode="widthFix" />
|
||||||
|
<view class="info-card">
|
||||||
|
<view class="title">{{ detail.title || detail.name || '-' }}</view>
|
||||||
|
<view class="price-row">
|
||||||
|
<view class="points-wrap">
|
||||||
|
<text class="points-val">{{ formatPoints( detail.price) }}</text>
|
||||||
|
<text class="points-unit">积分</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="stock" v-if="detail.stock !== null && detail.stock !== undefined">库存:{{ detail.stock }}</view>
|
||||||
|
<view class="desc" v-if="detail.description">
|
||||||
|
<rich-text :nodes="detail.description"></rich-text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="empty">商品不存在</view>
|
||||||
|
|
||||||
|
<!-- Action Bar Moved Outside info-card -->
|
||||||
|
<view class="action-bar-placeholder" v-if="detail.id"></view>
|
||||||
|
<view class="action-bar" v-if="detail.id">
|
||||||
|
<view
|
||||||
|
class="action-btn redeem"
|
||||||
|
:class="{ disabled: detail.stock === 0 }"
|
||||||
|
@tap="onRedeem"
|
||||||
|
>
|
||||||
|
{{ detail.stock === 0 ? '已售罄' : '立即兑换' }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { getProductDetail } from '../../api/appUser'
|
||||||
|
import { redeemProductByPoints } from '../../utils/request.js'
|
||||||
|
|
||||||
|
const detail = ref({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const isOutOfStock = ref(false)
|
||||||
|
|
||||||
|
function formatPrice(p) {
|
||||||
|
if (p === undefined || p === null) return '0.00'
|
||||||
|
return (Number(p) / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化积分显示 - 不四舍五入,保留两位小数
|
||||||
|
function formatPoints(value) {
|
||||||
|
if (value === undefined || value === null) return '0.00'
|
||||||
|
const num = Number(value)
|
||||||
|
if (isNaN(num)) return '0.00'
|
||||||
|
|
||||||
|
// 价格字段单位是分,如 1250 = 12.50积分
|
||||||
|
// 除以100得到显示值
|
||||||
|
const finalValue = num / 100
|
||||||
|
|
||||||
|
// 使用 Math.floor 避免四舍五入,保留两位小数
|
||||||
|
return String(Math.floor(finalValue * 100) / 100).replace(/(\.\d)$/, '$10')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail(id) {
|
||||||
|
loading.value = true
|
||||||
|
isOutOfStock.value = false
|
||||||
|
try {
|
||||||
|
const res = await getProductDetail(id)
|
||||||
|
detail.value = res || {}
|
||||||
|
console.log(detail.value);
|
||||||
|
if (detail.value.code === 20002 || detail.value.message === '商品缺货') {
|
||||||
|
// 商品缺货时标记状态,这样页面会显示缺货提示而不是"商品不存在"
|
||||||
|
// request.js 中已经会弹出提示窗口
|
||||||
|
isOutOfStock.value = true
|
||||||
|
console.log('[商品详情] 商品缺货')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
// 检查是否是商品缺货错误 (code: 20002)
|
||||||
|
const errorCode = e?.data?.code || e?.code
|
||||||
|
const errorMessage = e?.data?.message || e?.message || e?.msg
|
||||||
|
|
||||||
|
if (errorCode === 20002 || errorMessage === '商品缺货') {
|
||||||
|
// 商品缺货时标记状态,这样页面会显示缺货提示而不是"商品不存在"
|
||||||
|
// request.js 中已经会弹出提示窗口
|
||||||
|
isOutOfStock.value = true
|
||||||
|
console.log('[商品详情] 商品缺货')
|
||||||
|
} else {
|
||||||
|
// 其他错误才清空并显示"商品不存在"
|
||||||
|
detail.value = {}
|
||||||
|
console.log('[商品详情] 错误信息:', e)
|
||||||
|
uni.showToast({ title: errorMessage || '加载失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBuy() {
|
||||||
|
uni.showToast({ title: '暂未开放购买', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRedeem() {
|
||||||
|
const p = detail.value
|
||||||
|
if (!p || !p.id) return
|
||||||
|
|
||||||
|
// 检查商品库存
|
||||||
|
if (p.stock === 0) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '商品已售罄',
|
||||||
|
content: '该商品库存不足,请联系客服处理',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
if (!token) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '请先登录',
|
||||||
|
confirmText: '去登录',
|
||||||
|
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = formatPoints(p.price)
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认兑换',
|
||||||
|
content: `是否消耗 ${points} 积分兑换 ${p.title || p.name}?`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.showLoading({ title: '兑换中...' })
|
||||||
|
try {
|
||||||
|
const userId = uni.getStorageSync('user_id')
|
||||||
|
if (!userId) throw new Error('用户ID不存在')
|
||||||
|
|
||||||
|
await redeemProductByPoints(userId, p.id, 1)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showModal({
|
||||||
|
title: '兑换成功',
|
||||||
|
content: `您已成功兑换 ${p.title || p.name}`,
|
||||||
|
showCancel: false,
|
||||||
|
success: () => {
|
||||||
|
fetchDetail(p.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((opts) => {
|
||||||
|
const id = opts && opts.id
|
||||||
|
if (id) fetchDetail(id)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* ============================================
|
||||||
|
柯大鸭潮玩 - 商品详情页
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-page;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 背景装饰 - 漂浮光球 (与各主要页面统一) */
|
||||||
|
.bg-decoration {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -100rpx; right: -100rpx;
|
||||||
|
width: 600rpx; height: 600rpx;
|
||||||
|
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
|
||||||
|
filter: blur(60rpx);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.8;
|
||||||
|
animation: float 10s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 200rpx; left: -200rpx;
|
||||||
|
width: 500rpx; height: 500rpx;
|
||||||
|
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
|
||||||
|
filter: blur(50rpx);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 15s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translate(0, 0); }
|
||||||
|
50% { transform: translate(30rpx, 50rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 120rpx 40rpx;
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: $font-md;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-wrap {
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
animation: fadeInUp 0.4s ease-out;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 750rpx;
|
||||||
|
display: block;
|
||||||
|
background: $bg-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(20rpx);
|
||||||
|
border-radius: $radius-xl $radius-xl 0 0;
|
||||||
|
padding: $spacing-xl;
|
||||||
|
box-shadow: 0 -8rpx 32rpx rgba(0,0,0,0.05);
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
margin-top: -40rpx;
|
||||||
|
min-height: 50vh;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $text-main;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: $font-xxl;
|
||||||
|
font-weight: 900;
|
||||||
|
color: $brand-primary;
|
||||||
|
font-family: 'DIN Alternate', sans-serif;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '¥';
|
||||||
|
font-size: $font-md;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-wrap {
|
||||||
|
display: flex; align-items: baseline;
|
||||||
|
}
|
||||||
|
.points-val {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #FF9800;
|
||||||
|
font-family: 'DIN Alternate', sans-serif;
|
||||||
|
}
|
||||||
|
.points-unit {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #FF9800;
|
||||||
|
margin-left: 6rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-secondary;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6rpx $spacing-md;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text-main;
|
||||||
|
line-height: 1.8;
|
||||||
|
padding-top: $spacing-lg;
|
||||||
|
border-top: 1rpx dashed $border-color-light;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '商品详情';
|
||||||
|
display: block;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-secondary;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar-placeholder { height: 120rpx; }
|
||||||
|
.action-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
padding: 20rpx 40rpx;
|
||||||
|
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:active { transform: scale(0.96); }
|
||||||
|
}
|
||||||
|
.action-btn.redeem {
|
||||||
|
background: linear-gradient(135deg, #FFB74D, #FF9800);
|
||||||
|
box-shadow: 0 8rpx 20rpx rgba(255, 152, 0, 0.3);
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: #ccc;
|
||||||
|
box-shadow: none;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.action-btn.buy { background: linear-gradient(135deg, #FF6B6B, #FF3B30); box-shadow: 0 8rpx 20rpx rgba(255, 59, 48, 0.3); }
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(40rpx); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -8,17 +8,19 @@
|
|||||||
<text class="label">手机号</text>
|
<text class="label">手机号</text>
|
||||||
<input class="input" v-model="mobile" placeholder="请输入手机号" />
|
<input class="input" v-model="mobile" placeholder="请输入手机号" />
|
||||||
</view>
|
</view>
|
||||||
<view class="form-item">
|
<view class="form-item region-picker" @click="openRegionPicker">
|
||||||
<text class="label">省份</text>
|
<text class="label">省市区</text>
|
||||||
<input class="input" v-model="province" placeholder="请输入省份" />
|
<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>
|
</view>
|
||||||
<view class="form-item">
|
</picker>
|
||||||
<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>
|
</view>
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
<text class="label">详细地址</text>
|
<text class="label">详细地址</text>
|
||||||
@ -34,7 +36,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { addAddress, updateAddress, listAddresses, setDefaultAddress } from '../../api/appUser'
|
import { addAddress, updateAddress, listAddresses, setDefaultAddress } from '../../api/appUser'
|
||||||
|
|
||||||
@ -49,6 +51,25 @@ let isDefault = false
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
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) {
|
function fill(data) {
|
||||||
name.value = data.name || data.realname || ''
|
name.value = data.name || data.realname || ''
|
||||||
mobile.value = data.mobile || data.phone || ''
|
mobile.value = data.mobile || data.phone || ''
|
||||||
@ -150,7 +171,7 @@ onLoad((opts) => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* ============================================
|
/* ============================================
|
||||||
奇盒潮玩 - 地址编辑页面
|
柯大鸭潮玩 - 地址编辑页面
|
||||||
采用暖橙色调的表单设计
|
采用暖橙色调的表单设计
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@ -193,6 +214,37 @@ onLoad((opts) => {
|
|||||||
height: 48rpx;
|
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 {
|
.submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
472
pages-user/address/index.vue
Normal file
472
pages-user/address/index.vue
Normal 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>
|
||||||
393
pages-user/address/submit.vue
Normal file
393
pages-user/address/submit.vue
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
<template>
|
||||||
|
<view class="container">
|
||||||
|
<view class="header glass-card">
|
||||||
|
<view class="title">填写收货信息</view>
|
||||||
|
<view class="desc">好友正在为您申请奖品发货,请填写您的准确收货地址</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 已登录用户显示地址列表 -->
|
||||||
|
<view v-if="isLoggedIn && addressList.length > 0" class="address-list-section">
|
||||||
|
<view class="section-title">选择已保存的地址</view>
|
||||||
|
<view class="address-list">
|
||||||
|
<view
|
||||||
|
v-for="(addr, index) in addressList"
|
||||||
|
:key="addr.id || index"
|
||||||
|
class="address-card"
|
||||||
|
:class="{ selected: selectedAddressIndex === index }"
|
||||||
|
@tap="selectAddress(index)"
|
||||||
|
>
|
||||||
|
<view class="address-info">
|
||||||
|
<view class="address-header">
|
||||||
|
<text class="name">{{ addr.name }}</text>
|
||||||
|
<text class="mobile">{{ addr.mobile }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="address-detail">
|
||||||
|
{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.address }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="address-check" v-if="selectedAddressIndex === index">
|
||||||
|
<text class="check-icon">✓</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="divider">
|
||||||
|
<text class="divider-text">或填写新地址</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form glass-card">
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">收货人</text>
|
||||||
|
<input v-model="form.name" placeholder="请输入姓名" class="input" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">手机号码</text>
|
||||||
|
<input v-model="form.mobile" type="number" maxlength="11" placeholder="请输入手机号" class="input" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">地区</text>
|
||||||
|
<picker mode="region" @change="onRegionChange" class="input">
|
||||||
|
<view class="picker-value" v-if="form.province">
|
||||||
|
{{ form.province }} {{ form.city }} {{ form.district }}
|
||||||
|
</view>
|
||||||
|
<view class="picker-placeholder" v-else>请选择省市区</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">详细地址</text>
|
||||||
|
<textarea v-model="form.address" placeholder="街道、楼牌号等" class="textarea" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="footer-btn">
|
||||||
|
<button class="submit-btn" :loading="loading" @tap="onSubmit">确认提交</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tip-section">
|
||||||
|
<text class="tip-text">* 请确保信息准确,提交后无法修改</text>
|
||||||
|
<text class="tip-text" v-if="isLoggedIn">* 您已登录,提交后该奖品将转移至您的账户下</text>
|
||||||
|
<text class="tip-text" v-else>* 您当前未登录,提交后资产仍归属于原发起人</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { listAddresses } from '@/api/appUser'
|
||||||
|
|
||||||
|
const token = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const isLoggedIn = ref(!!uni.getStorageSync('token'))
|
||||||
|
const addressList = ref([])
|
||||||
|
const selectedAddressIndex = ref(-1)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
mobile: '',
|
||||||
|
province: '',
|
||||||
|
city: '',
|
||||||
|
district: '',
|
||||||
|
address: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
if (options.token) {
|
||||||
|
token.value = options.token
|
||||||
|
// 如果已登录,加载用户的地址列表
|
||||||
|
if (isLoggedIn.value) {
|
||||||
|
loadAddressList()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '参数错误', icon: 'none' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载用户地址列表
|
||||||
|
async function loadAddressList() {
|
||||||
|
if (!isLoggedIn.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = uni.getStorageSync('user_id')
|
||||||
|
if (!userId) return
|
||||||
|
|
||||||
|
const res = await listAddresses(userId)
|
||||||
|
addressList.value = res.list || res.data || res || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取地址列表失败:', e)
|
||||||
|
addressList.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择地址
|
||||||
|
function selectAddress(index) {
|
||||||
|
selectedAddressIndex.value = index
|
||||||
|
const addr = addressList.value[index]
|
||||||
|
if (addr) {
|
||||||
|
form.name = addr.name || ''
|
||||||
|
form.mobile = addr.mobile || ''
|
||||||
|
form.province = addr.province || ''
|
||||||
|
form.city = addr.city || ''
|
||||||
|
form.district = addr.district || ''
|
||||||
|
form.address = addr.address || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRegionChange(e) {
|
||||||
|
const [p, c, d] = e.detail.value
|
||||||
|
form.province = p
|
||||||
|
form.city = c
|
||||||
|
form.district = d
|
||||||
|
// 用户手动修改地区时,清除地址选择状态
|
||||||
|
selectedAddressIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!token.value) return
|
||||||
|
if (!form.name || !form.mobile || !form.province || !form.address) {
|
||||||
|
uni.showToast({ title: '请完善收货信息', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^1\d{10}$/.test(form.mobile)) {
|
||||||
|
uni.showToast({ title: '手机号格式错误', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await request({
|
||||||
|
url: '/api/app/address-share/submit',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
share_token: token.value,
|
||||||
|
...form
|
||||||
|
},
|
||||||
|
// 手动带上 token,因为公共 request 可能不带
|
||||||
|
header: {
|
||||||
|
'Authorization': uni.getStorageSync('token') || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: '提交成功',
|
||||||
|
content: '收货信息已提交,请等待发货。' + (isLoggedIn.value ? '资产已转移至您的盒柜。' : ''),
|
||||||
|
showCancel: false,
|
||||||
|
success: () => {
|
||||||
|
// #ifdef MP-TOUTIAO
|
||||||
|
// 抖音平台跳转到商城
|
||||||
|
uni.switchTab({ url: '/pages/shop/index' })
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-TOUTIAO
|
||||||
|
uni.switchTab({ url: '/pages/index/index' })
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.container {
|
||||||
|
padding: 30rpx;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-page;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 40rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
animation: fadeInDown 0.5s ease-out;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-main;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 地址列表部分 */
|
||||||
|
.address-list-section {
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
animation: fadeInUp 0.5s ease-out 0.1s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-main;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: $brand-primary;
|
||||||
|
background: rgba($brand-primary, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-info {
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-secondary;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-check {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $brand-primary;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 30rpx 0;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-text {
|
||||||
|
padding: 0 20rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: 20rpx 40rpx;
|
||||||
|
animation: fadeInUp 0.5s ease-out 0.2s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
padding: 30rpx 0;
|
||||||
|
border-bottom: 1rpx solid rgba(0,0,0,0.05);
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-main;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input, .textarea {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
height: 160rpx;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-placeholder { color: $text-tertiary; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn {
|
||||||
|
margin-top: 60rpx;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
background: $gradient-brand;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: $shadow-warm;
|
||||||
|
|
||||||
|
&:active { transform: scale(0.98); opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-section {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
758
pages-user/coupons/index.vue
Normal file
758
pages-user/coupons/index.vue
Normal file
@ -0,0 +1,758 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page-container">
|
||||||
|
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||||
|
<view class="bg-decoration"></view>
|
||||||
|
|
||||||
|
<view class="header-area">
|
||||||
|
<view class="page-title">我的优惠券</view>
|
||||||
|
<view class="page-subtitle">My Coupons</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab栏 - 毛玻璃风格 -->
|
||||||
|
<view class="tab-bar glass-card">
|
||||||
|
<view class="tab-item" :class="{ active: currentTab === 1 }" @click="switchTab(1)">
|
||||||
|
<text class="tab-text">未使用</text>
|
||||||
|
<view class="tab-indicator" v-if="currentTab === 1"></view>
|
||||||
|
</view>
|
||||||
|
<view class="tab-item" :class="{ active: currentTab === 2 }" @click="switchTab(2)">
|
||||||
|
<text class="tab-text">已使用</text>
|
||||||
|
<view class="tab-indicator" v-if="currentTab === 2"></view>
|
||||||
|
</view>
|
||||||
|
<view class="tab-item" :class="{ active: currentTab === 3 }" @click="switchTab(3)">
|
||||||
|
<text class="tab-text">已过期</text>
|
||||||
|
<view class="tab-indicator" v-if="currentTab === 3"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 内容区 -->
|
||||||
|
<scroll-view
|
||||||
|
scroll-y
|
||||||
|
class="content-scroll"
|
||||||
|
refresher-enabled
|
||||||
|
:refresher-triggered="isRefreshing"
|
||||||
|
@refresherrefresh="onRefresh"
|
||||||
|
@scrolltolower="loadMore"
|
||||||
|
>
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading && list.length === 0" class="loading-state">
|
||||||
|
<view class="spinner"></view>
|
||||||
|
<text>加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else-if="list.length === 0" class="empty-state">
|
||||||
|
<text class="empty-icon">🎟️</text>
|
||||||
|
<text class="empty-text">{{ getEmptyText() }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 优惠券列表 -->
|
||||||
|
<view v-else class="coupon-list">
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in list"
|
||||||
|
:key="item.id || index"
|
||||||
|
class="coupon-ticket"
|
||||||
|
:class="getCouponClass()"
|
||||||
|
:style="{ animationDelay: `${index * 0.05}s` }"
|
||||||
|
>
|
||||||
|
<!-- 左侧金额区域 -->
|
||||||
|
<view class="coupon-left">
|
||||||
|
<view class="coupon-value">
|
||||||
|
<text class="coupon-symbol">¥</text>
|
||||||
|
<text class="coupon-amount">{{ formatValue(item.remaining ?? item.amount ?? 0) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="coupon-label">{{ currentTab === 1 ? '可用' : (currentTab === 2 ? '已用' : '过期') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 中间分割线 -->
|
||||||
|
<view class="coupon-divider">
|
||||||
|
<view class="divider-notch top"></view>
|
||||||
|
<view class="divider-dash"></view>
|
||||||
|
<view class="divider-notch bottom"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 右侧信息区域 -->
|
||||||
|
<view class="coupon-right">
|
||||||
|
<view class="coupon-header">
|
||||||
|
<text class="coupon-name">{{ item.name || '优惠券' }}</text>
|
||||||
|
<view class="coupon-original" v-if="item.amount && item.remaining !== undefined && item.remaining !== item.amount">
|
||||||
|
<text>原值 ¥{{ formatValue(item.amount) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="coupon-rules">{{ formatRules(item.rules) }}</text>
|
||||||
|
|
||||||
|
<!-- 使用进度条 -->
|
||||||
|
<view class="coupon-progress" v-if="item.amount && item.remaining !== undefined && item.remaining < item.amount">
|
||||||
|
<view class="progress-bar">
|
||||||
|
<view class="progress-fill" :style="{ width: getUsedPercent(item) + '%' }"></view>
|
||||||
|
</view>
|
||||||
|
<text class="progress-text">已用 {{ formatValue(item.amount - item.remaining) }} ({{ getUsedPercent(item) }}%)</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="coupon-footer">
|
||||||
|
<view class="footer-left">
|
||||||
|
<text class="coupon-expire">{{ formatExpiry(item) }}</text>
|
||||||
|
<text class="coupon-used-time" v-if="currentTab === 2 && item.used_at">使用时间:{{ formatDateTime(item.used_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 优化后的按钮位置 -->
|
||||||
|
<view class="coupon-action-wrapper" v-if="currentTab === 1">
|
||||||
|
<view class="use-btn" @click.stop="onUseCoupon(item)">
|
||||||
|
<text class="btn-text">去使用</text>
|
||||||
|
<view class="btn-shine"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="coupon-status" v-else>
|
||||||
|
<text class="status-tag" :class="currentTab === 2 ? 'used' : 'expired'">{{ currentTab === 2 ? '已使用' : '已过期' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载更多 -->
|
||||||
|
<view v-if="loading && list.length > 0" class="loading-more">
|
||||||
|
<view class="spinner"></view>
|
||||||
|
<text>加载更多...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!hasMore && list.length > 0" class="no-more">- 到底啦 -</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { getUserCoupons } from '../../api/appUser'
|
||||||
|
import { vibrateShort } from '@/utils/vibrate.js'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const isRefreshing = ref(false)
|
||||||
|
const currentTab = ref(1)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const hasMore = ref(true)
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
function getUserId() {
|
||||||
|
return uni.getStorageSync('user_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查登录状态
|
||||||
|
function checkAuth() {
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
const userId = getUserId()
|
||||||
|
if (!token || !userId) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '请先登录',
|
||||||
|
confirmText: '去登录',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateTo({ url: '/pages/login/index' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化金额 (分转元)
|
||||||
|
function formatValue(val) {
|
||||||
|
return (Number(val) / 100).toFixed(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化优惠券规则描述中的分转为元
|
||||||
|
function formatRules(rules) {
|
||||||
|
if (!rules) return '全场通用'
|
||||||
|
// 将"XXX分"替换为"¥X.XX元"格式
|
||||||
|
return rules.replace(/(\d+)分/g, (match, p1) => {
|
||||||
|
const yuan = (Number(p1) / 100).toFixed(2)
|
||||||
|
// 去掉末尾的.00
|
||||||
|
const formatted = yuan.endsWith('.00') ? yuan.slice(0, -3) : yuan
|
||||||
|
return `¥${formatted}元`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化有效期
|
||||||
|
function formatExpiry(item) {
|
||||||
|
// 后端返回的字段是 valid_end
|
||||||
|
const endTime = item.valid_end || item.end_time
|
||||||
|
if (!endTime) return '长期有效'
|
||||||
|
const d = new Date(endTime)
|
||||||
|
// Check for invalid date (e.g., "0001-01-01" from Go zero value)
|
||||||
|
if (isNaN(d.getTime()) || d.getFullYear() < 2000) return '长期有效'
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const label = currentTab.value === 3 ? '过期时间' : '有效期至'
|
||||||
|
return `${label} ${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
function formatDateTime(t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const d = new Date(t)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day} ${hh}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算使用百分比
|
||||||
|
function getUsedPercent(item) {
|
||||||
|
if (!item.amount || !item.remaining) return 0
|
||||||
|
const used = item.amount - item.remaining
|
||||||
|
return Math.floor((used / item.amount) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取空状态文本
|
||||||
|
function getEmptyText() {
|
||||||
|
if (currentTab.value === 1) return '暂无可用优惠券'
|
||||||
|
if (currentTab.value === 2) return '暂无使用记录'
|
||||||
|
return '暂无过期优惠券'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取优惠券样式类
|
||||||
|
function getCouponClass() {
|
||||||
|
if (currentTab.value === 2) return 'coupon-used'
|
||||||
|
if (currentTab.value === 3) return 'coupon-expired'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换Tab
|
||||||
|
function switchTab(tab) {
|
||||||
|
if (currentTab.value === tab) return
|
||||||
|
vibrateShort()
|
||||||
|
currentTab.value = tab
|
||||||
|
list.value = []
|
||||||
|
page.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
async function onRefresh() {
|
||||||
|
isRefreshing.value = true
|
||||||
|
page.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
await fetchData(false)
|
||||||
|
isRefreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
async function loadMore() {
|
||||||
|
if (loading.value || !hasMore.value) return
|
||||||
|
await fetchData(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据
|
||||||
|
async function fetchData(append = false) {
|
||||||
|
if (!checkAuth()) return
|
||||||
|
if (loading.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const userId = getUserId()
|
||||||
|
// status: 0=unused, 1=used, 2=expired
|
||||||
|
const statusMap = { 1: 0, 2: 1, 3: 2 }
|
||||||
|
const res = await getUserCoupons(userId, statusMap[currentTab.value], page.value, pageSize)
|
||||||
|
const items = res.list || res.data || []
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
list.value = [...list.value, ...items]
|
||||||
|
} else {
|
||||||
|
list.value = items
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length < pageSize) {
|
||||||
|
hasMore.value = false
|
||||||
|
} else {
|
||||||
|
page.value++
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取优惠券失败:', e)
|
||||||
|
hasMore.value = false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去使用优惠券
|
||||||
|
function onUseCoupon(item) {
|
||||||
|
vibrateShort()
|
||||||
|
// #ifdef MP-TOUTIAO
|
||||||
|
// 抖音平台跳转到商城
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/shop/index'
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-TOUTIAO
|
||||||
|
// 通常跳转到首页或抽盒页
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/index/index'
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-page;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 背景装饰 - 漂浮光球 (与个人中心统一) */
|
||||||
|
.bg-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -100rpx; right: -100rpx;
|
||||||
|
width: 600rpx; height: 600rpx;
|
||||||
|
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
|
||||||
|
filter: blur(60rpx);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.8;
|
||||||
|
animation: float 10s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 200rpx; left: -200rpx;
|
||||||
|
width: 500rpx; height: 500rpx;
|
||||||
|
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
|
||||||
|
filter: blur(50rpx);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 15s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translate(0, 0); }
|
||||||
|
50% { transform: translate(30rpx, 50rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-area {
|
||||||
|
padding: $spacing-xl $spacing-lg;
|
||||||
|
padding-top: calc(env(safe-area-inset-top) + 20rpx);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: $text-main;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab栏 */
|
||||||
|
.tab-bar {
|
||||||
|
@extend .glass-card;
|
||||||
|
display: flex;
|
||||||
|
margin: 0 $spacing-lg;
|
||||||
|
padding: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active .tab-text {
|
||||||
|
color: $text-main;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 40rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
background: $brand-primary;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容滚动区 */
|
||||||
|
.content-scroll {
|
||||||
|
height: calc(100vh - 280rpx);
|
||||||
|
padding: $spacing-lg;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 26rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 80rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优惠券列表 */
|
||||||
|
.coupon-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优惠券卡片 */
|
||||||
|
.coupon-ticket {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
position: relative;
|
||||||
|
animation: fadeInUp 0.5s ease-out backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20rpx);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-left {
|
||||||
|
width: 180rpx;
|
||||||
|
background: linear-gradient(135deg, #FFF5E6, #fff);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-value {
|
||||||
|
color: $brand-primary;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-symbol {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-amount {
|
||||||
|
font-size: 56rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $brand-primary;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
border: 1px solid $brand-primary;
|
||||||
|
padding: 2rpx 8rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分割线 */
|
||||||
|
.coupon-divider {
|
||||||
|
width: 30rpx;
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-notch {
|
||||||
|
width: 24rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
background: $bg-page;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-notch.top {
|
||||||
|
top: -12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-notch.bottom {
|
||||||
|
bottom: -12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-dash {
|
||||||
|
width: 0;
|
||||||
|
height: 80%;
|
||||||
|
border-left: 2rpx dashed #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-right {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx;
|
||||||
|
padding-right: 130rpx; /* Prevent text overlap with button */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative; /* Ensure padding works with absolute button */
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-name {
|
||||||
|
font-size: $font-md;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-main;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-original {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
text-decoration: line-through;
|
||||||
|
margin-left: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-rules {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-sub;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条 */
|
||||||
|
.coupon-progress {
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 6rpx;
|
||||||
|
background: $bg-secondary;
|
||||||
|
border-radius: 100rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: $brand-primary;
|
||||||
|
border-radius: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-expire {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-used-time {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化后的按钮样式 */
|
||||||
|
.coupon-action-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
right: 24rpx;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-btn {
|
||||||
|
background: linear-gradient(135deg, #FF8D3F, #FF5C00);
|
||||||
|
padding: 12rpx 32rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
box-shadow: 0 6rpx 20rpx rgba(255, 92, 0, 0.3);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
box-shadow: 0 2rpx 10rpx rgba(255, 92, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-shine {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 255, 255, 0.3) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
transform: skewX(-25deg);
|
||||||
|
animation: shine 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shine {
|
||||||
|
0% { left: -100%; }
|
||||||
|
20%, 100% { left: 150%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
background: #F5F5F5;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过期/已使用状态 */
|
||||||
|
.coupon-used .coupon-left,
|
||||||
|
.coupon-expired .coupon-left {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-used .coupon-value,
|
||||||
|
.coupon-expired .coupon-value,
|
||||||
|
.coupon-used .coupon-label,
|
||||||
|
.coupon-expired .coupon-label {
|
||||||
|
color: $text-tertiary;
|
||||||
|
border-color: $text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-used .coupon-name,
|
||||||
|
.coupon-expired .coupon-name {
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载更多 */
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 24rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
border: 3rpx solid $bg-secondary;
|
||||||
|
border-top-color: $text-tertiary;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 24rpx;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -14,8 +14,8 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
toUser() { uni.navigateTo({ url: '/pages/agreement/user' }) },
|
toUser() { uni.navigateTo({ url: '/pages-user/agreement/user' }) },
|
||||||
toPurchase() { uni.navigateTo({ url: '/pages/agreement/purchase' }) }
|
toPurchase() { uni.navigateTo({ url: '/pages-user/agreement/purchase' }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
538
pages-user/invite/landing.vue
Normal file
538
pages-user/invite/landing.vue
Normal 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
Normal file
403
pages-user/invites/index.vue
Normal 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
Normal file
718
pages-user/item-cards/index.vue
Normal file
@ -0,0 +1,718 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page-container">
|
||||||
|
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||||
|
<view class="bg-decoration"></view>
|
||||||
|
|
||||||
|
<view class="header-area">
|
||||||
|
<view class="page-title">我的道具卡</view>
|
||||||
|
<view class="page-subtitle">My Item Cards</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab栏 - 毛玻璃风格 -->
|
||||||
|
<view class="tab-bar glass-card">
|
||||||
|
<view class="tab-item" :class="{ active: currentTab === 0 }" @click="switchTab(0)">
|
||||||
|
<text class="tab-text">未使用</text>
|
||||||
|
<view class="tab-indicator" v-if="currentTab === 0"></view>
|
||||||
|
</view>
|
||||||
|
<view class="tab-item" :class="{ active: currentTab === 1 }" @click="switchTab(1)">
|
||||||
|
<text class="tab-text">已使用</text>
|
||||||
|
<view class="tab-indicator" v-if="currentTab === 1"></view>
|
||||||
|
</view>
|
||||||
|
<view class="tab-item" :class="{ active: currentTab === 2 }" @click="switchTab(2)">
|
||||||
|
<text class="tab-text">已过期</text>
|
||||||
|
<view class="tab-indicator" v-if="currentTab === 2"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 内容区 -->
|
||||||
|
<scroll-view
|
||||||
|
scroll-y
|
||||||
|
class="content-scroll"
|
||||||
|
refresher-enabled
|
||||||
|
:refresher-triggered="isRefreshing"
|
||||||
|
@refresherrefresh="onRefresh"
|
||||||
|
@scrolltolower="loadMore"
|
||||||
|
>
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading && list.length === 0" class="loading-state">
|
||||||
|
<view class="spinner"></view>
|
||||||
|
<text>加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else-if="list.length === 0" class="empty-state">
|
||||||
|
<text class="empty-icon">🃏</text>
|
||||||
|
<text class="empty-text">{{ getEmptyText() }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 道具卡列表 -->
|
||||||
|
<view v-else class="item-list">
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in list"
|
||||||
|
:key="item.id || index"
|
||||||
|
class="item-ticket"
|
||||||
|
:class="{ 'used': currentTab === 1, 'expired': currentTab === 2 }"
|
||||||
|
:style="{ animationDelay: `${index * 0.05}s` }"
|
||||||
|
>
|
||||||
|
<!-- 左侧图标区域 -->
|
||||||
|
<view class="ticket-left">
|
||||||
|
<view class="card-icon-wrap">
|
||||||
|
<text class="card-icon">{{ getCardIcon(item.type || item.name) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 中间分割线 -->
|
||||||
|
<view class="ticket-divider">
|
||||||
|
<view class="divider-notch top"></view>
|
||||||
|
<view class="divider-dash"></view>
|
||||||
|
<view class="divider-notch bottom"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 右侧信息区域 -->
|
||||||
|
<view class="ticket-right">
|
||||||
|
<view class="card-info">
|
||||||
|
<text class="card-name">{{ item.name || item.title || '道具卡' }}</text>
|
||||||
|
<text class="card-desc">{{ item.description || item.rules || '可在抽奖时使用' }}</text>
|
||||||
|
<view class="usage-info" v-if="currentTab === 1">
|
||||||
|
<text class="card-use-time" v-if="item.used_at">使用时间:{{ formatDateTime(item.used_at) }}</text>
|
||||||
|
<view class="usage-detail" v-if="item.used_activity_name">
|
||||||
|
<text class="detail-label">使用于:</text>
|
||||||
|
<text class="detail-val">{{ item.used_activity_name }}</text>
|
||||||
|
<text class="detail-val" v-if="item.used_issue_number"> - 期号 {{ item.used_issue_number }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="usage-detail" v-if="item.used_reward_name">
|
||||||
|
<text class="detail-label">效果:</text>
|
||||||
|
<text class="detail-val highlight">{{ item.used_reward_name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="usage-info" v-if="currentTab === 2">
|
||||||
|
<text class="card-use-time" v-if="item.valid_end">过期时间:{{ formatDateTime(item.valid_end) }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- Unused State: Show Validity -->
|
||||||
|
<view class="usage-info" v-if="currentTab === 0">
|
||||||
|
<text class="card-use-time" v-if="item.valid_end">有效期至:{{ formatDateTime(item.valid_end) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 优化后的按钮位置 -->
|
||||||
|
<view class="ticket-action-wrapper" v-if="currentTab === 0">
|
||||||
|
<view class="use-btn" @click.stop="onUseCard(item)">
|
||||||
|
<text class="btn-text">去使用</text>
|
||||||
|
<view class="btn-shine"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-used-badge" v-else-if="currentTab === 1">
|
||||||
|
<text class="used-text">已使用</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-used-badge expired" v-else-if="currentTab === 2">
|
||||||
|
<text class="used-text">已过期</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载更多 -->
|
||||||
|
<view v-if="loading && list.length > 0" class="loading-more">
|
||||||
|
<view class="spinner"></view>
|
||||||
|
<text>加载更多...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!hasMore && list.length > 0" class="no-more">- 到底啦 -</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { getItemCards } from '../../api/appUser'
|
||||||
|
import { vibrateShort } from '@/utils/vibrate.js'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const isRefreshing = ref(false)
|
||||||
|
const currentTab = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const hasMore = ref(true)
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
function getUserId() {
|
||||||
|
return uni.getStorageSync('user_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmptyText() {
|
||||||
|
if (currentTab.value === 0) return '暂无可用道具卡'
|
||||||
|
if (currentTab.value === 1) return '暂无使用记录'
|
||||||
|
if (currentTab.value === 2) return '暂无过期道具卡'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查登录状态
|
||||||
|
function checkAuth() {
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
const userId = getUserId()
|
||||||
|
if (!token || !userId) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '请先登录',
|
||||||
|
confirmText: '去登录',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateTo({ url: '/pages/login/index' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
function formatDateTime(t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const d = new Date(t)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取卡片图标
|
||||||
|
function getCardIcon(type) {
|
||||||
|
const t = String(type || '').toLowerCase()
|
||||||
|
if (t.includes('透视')) return '👁️'
|
||||||
|
if (t.includes('提示')) return '💡'
|
||||||
|
if (t.includes('重置')) return '🔄'
|
||||||
|
if (t.includes('翻倍')) return '✨'
|
||||||
|
if (t.includes('保护')) return '🛡️'
|
||||||
|
return '🃏'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换Tab
|
||||||
|
function switchTab(tab) {
|
||||||
|
if (currentTab.value === tab) return
|
||||||
|
vibrateShort()
|
||||||
|
currentTab.value = tab
|
||||||
|
list.value = []
|
||||||
|
page.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
async function onRefresh() {
|
||||||
|
isRefreshing.value = true
|
||||||
|
page.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
await fetchData(false)
|
||||||
|
isRefreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
async function loadMore() {
|
||||||
|
if (loading.value || !hasMore.value) return
|
||||||
|
await fetchData(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据
|
||||||
|
async function fetchData(append = false) {
|
||||||
|
if (!checkAuth()) return
|
||||||
|
if (loading.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const userId = getUserId()
|
||||||
|
// status: 1=unused, 2=used, 3=expired
|
||||||
|
const status = currentTab.value === 0 ? 1 : (currentTab.value === 1 ? 2 : 3)
|
||||||
|
const res = await getItemCards(userId, status, page.value, pageSize)
|
||||||
|
|
||||||
|
let items = Array.isArray(res) ? res : (res.list || res.data || [])
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
list.value = [...list.value, ...items]
|
||||||
|
} else {
|
||||||
|
list.value = items
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length < pageSize) {
|
||||||
|
hasMore.value = false
|
||||||
|
} else {
|
||||||
|
page.value++
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取道具卡失败:', e)
|
||||||
|
hasMore.value = false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去使用道具卡
|
||||||
|
function onUseCard(item) {
|
||||||
|
vibrateShort()
|
||||||
|
// #ifdef MP-TOUTIAO
|
||||||
|
// 抖音平台跳转到商城
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/shop/index'
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-TOUTIAO
|
||||||
|
// 道具卡通常去首页或指定的活动页
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/index/index'
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-page;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.header-area {
|
||||||
|
padding: $spacing-xl $spacing-lg;
|
||||||
|
padding-top: calc(env(safe-area-inset-top) + 20rpx);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: $text-main;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab栏 */
|
||||||
|
.tab-bar {
|
||||||
|
@extend .glass-card;
|
||||||
|
display: flex;
|
||||||
|
margin: 0 $spacing-lg;
|
||||||
|
padding: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active .tab-text {
|
||||||
|
color: $text-main;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 40rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
background: $brand-primary;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容滚动区 */
|
||||||
|
.content-scroll {
|
||||||
|
height: calc(100vh - 280rpx);
|
||||||
|
padding: $spacing-lg;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 26rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 80rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 道具卡列表 */
|
||||||
|
.item-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 票券式卡片 */
|
||||||
|
.item-ticket {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
position: relative;
|
||||||
|
animation: fadeInUp 0.5s ease-out backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20rpx);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-left {
|
||||||
|
width: 180rpx;
|
||||||
|
background: linear-gradient(135deg, #E6F7FF, #fff);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon-wrap {
|
||||||
|
width: 90rpx;
|
||||||
|
height: 90rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 150, 250, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
font-size: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-count-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12rpx;
|
||||||
|
right: 12rpx;
|
||||||
|
background: rgba(0, 150, 250, 0.1);
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-num {
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0096FA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分割线 */
|
||||||
|
.ticket-divider {
|
||||||
|
width: 30rpx;
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-notch {
|
||||||
|
width: 24rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
background: $bg-page;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-notch.top {
|
||||||
|
top: -12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-notch.bottom {
|
||||||
|
bottom: -12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-dash {
|
||||||
|
width: 0;
|
||||||
|
height: 80%;
|
||||||
|
border-left: 2rpx dashed #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-right {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-right: 130rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: $font-md;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-sub;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-sub;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-info {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding-top: 12rpx;
|
||||||
|
border-top: 1rpx dashed #eee;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-use-time {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-detail {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-val {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 4rpx;
|
||||||
|
|
||||||
|
&.highlight {
|
||||||
|
color: $brand-primary;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮样式 */
|
||||||
|
.ticket-action-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
right: 24rpx;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-btn {
|
||||||
|
background: linear-gradient(135deg, #4facfe, #00f2fe);
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
box-shadow: 0 6rpx 20rpx rgba(0, 150, 250, 0.2);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
box-shadow: 0 2rpx 10rpx rgba(0, 150, 250, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-shine {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 255, 255, 0.3) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
transform: skewX(-25deg);
|
||||||
|
animation: shine 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shine {
|
||||||
|
0% { left: -100%; }
|
||||||
|
20%, 100% { left: 150%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-used-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: 24rpx;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: #F5F5F5;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.used-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已使用状态 */
|
||||||
|
.item-ticket.used {
|
||||||
|
.ticket-left {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon-wrap {
|
||||||
|
filter: grayscale(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已过期状态 */
|
||||||
|
.item-ticket.expired {
|
||||||
|
.ticket-left {
|
||||||
|
background: #fdfdfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon-wrap {
|
||||||
|
filter: grayscale(1) sepia(0.2);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
color: $text-tertiary;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-used-badge.expired {
|
||||||
|
background: #f0f0f0;
|
||||||
|
|
||||||
|
.used-text {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载动画 */
|
||||||
|
.spinner {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
border: 3rpx solid $bg-secondary;
|
||||||
|
border-top-color: $text-tertiary;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载更多 */
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 24rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 24rpx;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
920
pages-user/orders/detail.vue
Normal file
920
pages-user/orders/detail.vue
Normal file
@ -0,0 +1,920 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page-container">
|
||||||
|
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||||
|
<view class="bg-decoration"></view>
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading" class="loading-state">
|
||||||
|
<view class="loading-spinner"></view>
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<view v-else-if="error" class="error-state">
|
||||||
|
<view class="error-icon">⚠️</view>
|
||||||
|
<text class="error-text">{{ error }}</text>
|
||||||
|
<button class="retry-btn" @tap="loadOrder">重试</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 订单内容 -->
|
||||||
|
<view v-else-if="order" class="content">
|
||||||
|
<!-- 状态头部背景 -->
|
||||||
|
<view class="status-header-bg" :class="getStatusClass(order)">
|
||||||
|
<view class="bg-circle c1"></view>
|
||||||
|
<view class="bg-circle c2"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 状态卡片 -->
|
||||||
|
<view class="status-card">
|
||||||
|
<view class="status-content">
|
||||||
|
<view class="status-icon-wrap">
|
||||||
|
<text class="status-icon">{{ getStatusIcon(order) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="status-info">
|
||||||
|
<text class="status-title">{{ statusText(order) }}</text>
|
||||||
|
<text class="status-desc" v-if="order.status === 1">请在 15 分钟内完成支付</text>
|
||||||
|
<text class="status-desc" v-else-if="order.status === 3">订单已取消</text>
|
||||||
|
<text class="status-desc" v-else>感谢您的购买,期待再次光临</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 奖品/商品列表 -->
|
||||||
|
<view class="section-card product-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">商品清单</text>
|
||||||
|
<text class="item-count">共 {{ order.items ? order.items.length : (order.activity_name ? 1 : 0) }} 件</text>
|
||||||
|
</view>
|
||||||
|
<view class="order-items">
|
||||||
|
<!-- 常规商品列表 -->
|
||||||
|
<view v-for="(item, index) in order.items" :key="index" class="item-card">
|
||||||
|
<view class="item-image-wrap">
|
||||||
|
<image class="item-image" :src="getProductImage(item)" mode="aspectFill" />
|
||||||
|
<!-- 购买标识 -->
|
||||||
|
<view class="winner-tag" v-if="order.is_winner && (order.source_type === 2 || order.source_type === 3)">
|
||||||
|
<text class="tag-text">已开启</text>
|
||||||
|
</view>
|
||||||
|
<view class="level-tag" v-if="order.reward_level">
|
||||||
|
<text class="tag-text">{{ getLevelLabel(order.reward_level) }}赏</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="item-info">
|
||||||
|
<text class="item-title">{{ item.title || '商品' }}</text>
|
||||||
|
<view class="item-tags" v-if="order.activity_name">
|
||||||
|
<text class="tag">{{ order.activity_name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-meta">
|
||||||
|
<view class="price-wrap">
|
||||||
|
<text class="currency" v-if="item.price > 0">¥</text>
|
||||||
|
<text class="price" v-if="item.price > 0">{{ formatPrice(item.price) }}</text>
|
||||||
|
<text class="price" v-else-if="order.points_amount > 0">{{ Math.floor(order.points_amount / (order.items && order.items.length || 1)) }}积分</text>
|
||||||
|
<text class="price" v-else>奖品</text>
|
||||||
|
</view>
|
||||||
|
<text class="item-quantity">x{{ item.quantity }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 活动信息(当没有实物商品时显示) -->
|
||||||
|
<view v-if="(!order.items || order.items.length === 0) && order.activity_name" class="item-card">
|
||||||
|
<view class="item-image-wrap">
|
||||||
|
<image class="item-image" :src="defaultImage" mode="aspectFill" />
|
||||||
|
<view class="winner-tag" v-if="order.is_winner">
|
||||||
|
<text class="tag-text">已开启</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="item-info">
|
||||||
|
<text class="item-title">{{ order.activity_name }}</text>
|
||||||
|
<view class="item-tags">
|
||||||
|
<text class="tag">参与记录</text>
|
||||||
|
<text class="tag" v-if="order.issue_number">第{{ order.issue_number }}期</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-meta">
|
||||||
|
<view class="price-wrap">
|
||||||
|
<text class="currency" v-if="order.actual_amount > 0">¥</text>
|
||||||
|
<text class="price" v-if="order.actual_amount > 0">{{ formatPrice(order.actual_amount) }}</text>
|
||||||
|
<text class="price" v-else-if="order.points_amount > 0">{{ order.points_amount }}积分</text>
|
||||||
|
<text class="price" v-else>奖品</text>
|
||||||
|
</view>
|
||||||
|
<text class="item-quantity">x1</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 订单信息 -->
|
||||||
|
<view class="section-card info-section">
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">订单编号</text>
|
||||||
|
<view class="value-wrap">
|
||||||
|
<text class="value mono">{{ order.order_no }}</text>
|
||||||
|
<view class="copy-btn" @tap="copyText(order.order_no)">复制</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">下单时间</text>
|
||||||
|
<text class="value">{{ formatTime(order.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" v-if="order.paid_at">
|
||||||
|
<text class="label">支付时间</text>
|
||||||
|
<text class="value">{{ formatTime(order.paid_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" v-if="order.cancelled_at">
|
||||||
|
<text class="label">取消时间</text>
|
||||||
|
<text class="value">{{ formatTime(order.cancelled_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">订单来源</text>
|
||||||
|
<text class="value">{{ getSourceTypeText(order.source_type) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 金额明细 -->
|
||||||
|
<view class="section-card amount-section">
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label">商品总额</text>
|
||||||
|
<text class="value" v-if="order.points_amount > 0">{{ order.points_amount }}积分</text>
|
||||||
|
<text class="value" v-else>¥{{ formatPrice(order.total_amount) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 优惠券 -->
|
||||||
|
<view class="info-row" v-if="order.coupon_info">
|
||||||
|
<text class="label">优惠券</text>
|
||||||
|
<view class="value-wrap">
|
||||||
|
<text class="tag-small coupon">{{ order.coupon_info.name }}</text>
|
||||||
|
<text class="value discount">-¥{{ formatPrice(order.coupon_info.value) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 道具卡 -->
|
||||||
|
<view class="info-row" v-if="order.item_card_info">
|
||||||
|
<text class="label">道具卡</text>
|
||||||
|
<view class="value-wrap">
|
||||||
|
<text class="tag-small card">{{ order.item_card_info.name }}</text>
|
||||||
|
<text class="value" v-if="order.item_card_info.effect_type === 1">双倍奖励</text>
|
||||||
|
<text class="value" v-else>已使用</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="info-row" v-if="order.discount_amount && !order.coupon_info">
|
||||||
|
<text class="label">其他优惠</text>
|
||||||
|
<text class="value discount">-¥{{ formatPrice(order.discount_amount) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view class="total-row">
|
||||||
|
<text class="total-label">{{ order.actual_amount > 0 ? '实付款' : '状态' }}</text>
|
||||||
|
<view class="total-price-wrap">
|
||||||
|
<text class="currency" v-if="order.actual_amount > 0">¥</text>
|
||||||
|
<text class="total-price" v-if="order.actual_amount > 0">{{ formatPrice(order.actual_amount) }}</text>
|
||||||
|
<text class="total-price" v-else-if="order.points_amount > 0">{{ order.points_amount }}积分</text>
|
||||||
|
<text class="total-price" v-else>无需支付</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 抽奖凭证(有凭证数据时显示) -->
|
||||||
|
<view class="section-card proof-section" v-if="order.draw_receipts && order.draw_receipts.length > 0">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">抽奖凭证</text>
|
||||||
|
<text class="item-count help-btn" @tap="showProofHelp">?</text>
|
||||||
|
</view>
|
||||||
|
<view v-for="(receipt, idx) in order.draw_receipts" :key="idx" class="receipt-block">
|
||||||
|
<view class="info-row" v-if="receipt.algo_version">
|
||||||
|
<text class="label">算法版本</text>
|
||||||
|
<text class="value mono">{{ receipt.algo_version }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" v-if="receipt.server_seed_hash">
|
||||||
|
<text class="label">服务端种子哈希</text>
|
||||||
|
<view class="value-wrap">
|
||||||
|
<text class="value mono seed-text">{{ receipt.server_seed_hash }}</text>
|
||||||
|
<view class="copy-btn" @tap="copyText(receipt.server_seed_hash)">复制</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" v-if="receipt.server_sub_seed">
|
||||||
|
<text class="label">子种子</text>
|
||||||
|
<view class="value-wrap">
|
||||||
|
<text class="value mono seed-text">{{ receipt.server_sub_seed }}</text>
|
||||||
|
<view class="copy-btn" @tap="copyText(receipt.server_sub_seed)">复制</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" v-if="receipt.client_seed">
|
||||||
|
<text class="label">客户端种子</text>
|
||||||
|
<text class="value mono">{{ receipt.client_seed }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" v-if="receipt.draw_id">
|
||||||
|
<text class="label">抽奖ID</text>
|
||||||
|
<text class="value mono">{{ receipt.draw_id }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" v-if="receipt.timestamp">
|
||||||
|
<text class="label">时间戳</text>
|
||||||
|
<text class="value mono">{{ receipt.timestamp }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" v-if="receipt.round_id">
|
||||||
|
<text class="label">期次ID</text>
|
||||||
|
<text class="value">{{ receipt.round_id }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="proof-notice">
|
||||||
|
<text class="notice-icon">🔒</text>
|
||||||
|
<text class="notice-text">以上数据可用于验证抽奖结果的公正性</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<view class="footer-actions safe-area-bottom" v-if="order && order.status === 1">
|
||||||
|
<view class="action-btn secondary" @tap="handleCancel">取消订单</view>
|
||||||
|
<view class="action-btn primary" @tap="handlePay">立即支付</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { getOrderDetail, cancelOrder, createWechatOrder } from '../../api/appUser'
|
||||||
|
import { levelToAlpha } from '@/utils/activity'
|
||||||
|
|
||||||
|
const orderId = ref('')
|
||||||
|
const order = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const defaultImage = 'https://keaiya-1259195914.cos.ap-shanghai.myqcloud.com/images/default-product.png'
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
if (options.id) {
|
||||||
|
orderId.value = options.id
|
||||||
|
loadOrder()
|
||||||
|
} else {
|
||||||
|
error.value = '参数错误'
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadOrder() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await getOrderDetail(orderId.value)
|
||||||
|
order.value = res
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || '获取订单详情失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认取消',
|
||||||
|
content: '确定要取消这个订单吗?',
|
||||||
|
confirmColor: '#FF6B00',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.showLoading({ title: '取消中...' })
|
||||||
|
try {
|
||||||
|
await cancelOrder(orderId.value, '用户主动取消')
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '订单已取消', icon: 'success' })
|
||||||
|
loadOrder()
|
||||||
|
} catch (e) {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: e.message || '取消失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePay() {
|
||||||
|
const openid = uni.getStorageSync('openid')
|
||||||
|
if (!openid) {
|
||||||
|
uni.showToast({ title: '缺少OpenID,请重新登录', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ord = order.value
|
||||||
|
if (!ord || !ord.order_no) return
|
||||||
|
uni.showLoading({ title: '拉起支付...' })
|
||||||
|
createWechatOrder({ openid, order_no: ord.order_no })
|
||||||
|
.then((payRes) => new Promise((resolve, reject) => {
|
||||||
|
uni.requestPayment({
|
||||||
|
provider: 'wxpay',
|
||||||
|
timeStamp: payRes.timeStamp || payRes.timestamp,
|
||||||
|
nonceStr: payRes.nonceStr || payRes.noncestr,
|
||||||
|
package: payRes.package,
|
||||||
|
signType: payRes.signType || 'MD5',
|
||||||
|
paySign: payRes.paySign,
|
||||||
|
success: resolve,
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
.then(async () => {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '支付成功', icon: 'success' })
|
||||||
|
// 支付成功后跳转到对应游戏页面
|
||||||
|
navigateToGame(ord)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
uni.hideLoading()
|
||||||
|
if (e?.errMsg && String(e.errMsg).includes('cancel')) {
|
||||||
|
uni.showToast({ title: '支付已取消', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showToast({ title: e?.message || '支付失败', icon: 'none' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToGame(ord) {
|
||||||
|
const playType = ord.play_type
|
||||||
|
const activityId = ord.activity_id
|
||||||
|
|
||||||
|
if (!activityId) {
|
||||||
|
// 没有活动ID,返回订单列表
|
||||||
|
uni.navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = ''
|
||||||
|
if (playType === 'match') {
|
||||||
|
url = `/pages-activity/activity/duiduipeng/index?activity_id=${activityId}`
|
||||||
|
} else if (playType === 'ichiban') {
|
||||||
|
url = `/pages-activity/activity/yifanshang/index?activity_id=${activityId}`
|
||||||
|
} else if (playType === 'infinity') {
|
||||||
|
url = `/pages-activity/activity/wuxianshang/index?activity_id=${activityId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
uni.redirectTo({ url })
|
||||||
|
} else {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
if (!text) return
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: String(text),
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '已复制', icon: 'none' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price) {
|
||||||
|
if (price === undefined || price === null) return '0.00'
|
||||||
|
return (Number(price) / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const date = new Date(t)
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(date.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const ss = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductImage(item) {
|
||||||
|
if (item.product_images) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(item.product_images)
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
return parsed[0]
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof item.product_images === 'string' && item.product_images.startsWith('http')) {
|
||||||
|
return item.product_images
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultImage
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(item) {
|
||||||
|
const status = item.status
|
||||||
|
if (status === 1) return '待付款'
|
||||||
|
if (status === 2) return '已完成'
|
||||||
|
if (status === 3) return '已取消'
|
||||||
|
return '进行中'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClass(item) {
|
||||||
|
const status = item.status
|
||||||
|
if (status === 1) return 'status-pending'
|
||||||
|
if (status === 2) return 'status-completed'
|
||||||
|
if (status === 3) return 'status-cancelled'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusIcon(item) {
|
||||||
|
const status = item.status
|
||||||
|
if (status === 1) return '🕒'
|
||||||
|
if (status === 2) return '🎉'
|
||||||
|
if (status === 3) return '🚫'
|
||||||
|
return '📦'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceTypeText(type) {
|
||||||
|
if (type === 1) return '商城订单'
|
||||||
|
if (type === 2 || type === 3) {
|
||||||
|
// 优先使用分类名称,其次活动名称,最后根据玩法类型显示
|
||||||
|
if (order.value && order.value.category_name) return order.value.category_name
|
||||||
|
if (order.value && order.value.activity_name) return order.value.activity_name
|
||||||
|
const playType = order.value && order.value.play_type
|
||||||
|
if (playType === 'match') return '对对碰'
|
||||||
|
if (playType === 'ichiban') return '一番赏'
|
||||||
|
if (type === 2) return '抽奖订单'
|
||||||
|
return '发奖记录'
|
||||||
|
}
|
||||||
|
return '其他'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelLabel(level) {
|
||||||
|
return levelToAlpha(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProofHelp() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '抽奖凭证说明',
|
||||||
|
content: '该凭证包含本次抽奖的随机种子和参数,可用于验证抽奖的公平性。您可以复制相关数据,自行进行核验。',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-page;
|
||||||
|
padding-bottom: calc(140rpx + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 背景装饰 - 漂浮光球 (与各主要页面统一) */
|
||||||
|
.bg-decoration {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -100rpx; right: -100rpx;
|
||||||
|
width: 600rpx; height: 600rpx;
|
||||||
|
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
|
||||||
|
filter: blur(60rpx);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.8;
|
||||||
|
animation: float 10s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 200rpx; left: -200rpx;
|
||||||
|
width: 500rpx; height: 500rpx;
|
||||||
|
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
|
||||||
|
filter: blur(50rpx);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 15s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translate(0, 0); }
|
||||||
|
50% { transform: translate(30rpx, 50rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态头部背景 */
|
||||||
|
.status-header-bg {
|
||||||
|
height: 150rpx;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0 0 40rpx 40rpx;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&.status-pending { background: linear-gradient(135deg, #FF9F43 0%, #FF6B6B 100%); }
|
||||||
|
&.status-completed { background: linear-gradient(135deg, #2ECC71 0%, #27AE60 100%); }
|
||||||
|
&.status-cancelled { background: linear-gradient(135deg, #95A5A6 0%, #7F8C8D 100%); }
|
||||||
|
|
||||||
|
.bg-circle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
|
||||||
|
&.c1 { width: 300rpx; height: 300rpx; top: -100rpx; right: -50rpx; }
|
||||||
|
&.c2 { width: 200rpx; height: 200rpx; bottom: 50rpx; left: -50rpx; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态卡片 */
|
||||||
|
.status-card {
|
||||||
|
margin: -60rpx $spacing-lg 0;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(20rpx);
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
padding: $spacing-xl;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
.status-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-wrap {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
background: $bg-secondary;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
font-size: 44rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-desc {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用卡片样式 */
|
||||||
|
.section-card {
|
||||||
|
margin: $spacing-lg;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(20rpx);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
animation: slideUp 0.4s ease-out;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(20rpx); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
padding-bottom: $spacing-sm;
|
||||||
|
border-bottom: 2rpx dashed $border-color-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-main;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 20rpx;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 6rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
background: $brand-primary;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-count {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 商品列表 */
|
||||||
|
.item-card {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
|
||||||
|
&:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-image-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $bg-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: $gradient-gold;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 0 0 $radius-md 0;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
.tag-text {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-tag {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: $radius-sm 0 0 0;
|
||||||
|
|
||||||
|
.tag-text {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-main;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
@include text-ellipsis(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tags {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $brand-primary;
|
||||||
|
background: rgba($brand-primary, 0.08);
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
color: $text-main;
|
||||||
|
|
||||||
|
.currency { font-size: 24rpx; font-weight: 600; }
|
||||||
|
.price { font-size: 32rpx; font-weight: 700; font-family: 'DIN Alternate', sans-serif; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-quantity {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息行 */
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-main;
|
||||||
|
|
||||||
|
&.mono { font-family: monospace; }
|
||||||
|
&.discount { color: $uni-color-error; font-weight: 600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
border: 1rpx solid $border-color;
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.6;
|
||||||
|
background: $bg-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-small {
|
||||||
|
font-size: 20rpx;
|
||||||
|
padding: 2rpx 8rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
|
||||||
|
&.coupon {
|
||||||
|
color: #FF6B6B;
|
||||||
|
background: rgba(255, 107, 107, 0.1);
|
||||||
|
border: 1rpx solid rgba(255, 107, 107, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.card {
|
||||||
|
color: #6C5CE7;
|
||||||
|
background: rgba(108, 92, 231, 0.1);
|
||||||
|
border: 1rpx solid rgba(108, 92, 231, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1rpx;
|
||||||
|
background: $border-color-light;
|
||||||
|
margin: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-top: 10rpx;
|
||||||
|
|
||||||
|
.total-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-price-wrap {
|
||||||
|
color: $brand-primary;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
.currency { font-size: 28rpx; font-weight: 600; }
|
||||||
|
.total-price { font-size: 40rpx; font-weight: 800; font-family: 'DIN Alternate', sans-serif; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部操作栏 */
|
||||||
|
.footer-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(20rpx);
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 24rpx;
|
||||||
|
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
height: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 48rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:active { transform: scale(0.96); }
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: $text-main;
|
||||||
|
border: 2rpx solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: $gradient-brand;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: $shadow-warm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading & Error */
|
||||||
|
.loading-state, .error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 80vh;
|
||||||
|
|
||||||
|
.loading-text, .error-text {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border: 6rpx solid rgba($brand-primary, 0.2);
|
||||||
|
border-top-color: $brand-primary;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
background: $brand-primary;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
padding: 12rpx 48rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 抽奖凭证区 */
|
||||||
|
.proof-section {
|
||||||
|
.seed-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 360rpx;
|
||||||
|
@include text-ellipsis(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
background: rgba($brand-primary, 0.06);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
|
||||||
|
.notice-icon {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page-container">
|
<view class="page-container">
|
||||||
|
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||||
|
<view class="bg-decoration"></view>
|
||||||
<!-- 顶部 Tab -->
|
<!-- 顶部 Tab -->
|
||||||
<view class="tabs">
|
<view class="tabs glass-card">
|
||||||
<view
|
<view
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
:class="{ active: currentTab === 'pending' }"
|
:class="{ active: currentTab === 'pending' }"
|
||||||
@ -72,7 +74,7 @@
|
|||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
<view class="image-overlay" v-if="item.is_winner">
|
<view class="image-overlay" v-if="item.is_winner">
|
||||||
<text class="winner-badge">🎉 中奖</text>
|
<text class="winner-badge">🎉 已开启</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -82,6 +84,8 @@
|
|||||||
<view class="product-meta">
|
<view class="product-meta">
|
||||||
<text class="meta-item" v-if="item.activity_name">{{ item.activity_name }}</text>
|
<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" 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>
|
</view>
|
||||||
<text class="order-time">{{ formatTime(item.created_at) }}</text>
|
<text class="order-time">{{ formatTime(item.created_at) }}</text>
|
||||||
</view>
|
</view>
|
||||||
@ -94,14 +98,14 @@
|
|||||||
<text class="no-value">{{ item.order_no }}</text>
|
<text class="no-value">{{ item.order_no }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="order-amount">
|
<view class="order-amount">
|
||||||
<text class="amount-label">实付</text>
|
<text class="amount-label" v-if="shouldShowAmountLabel(item)">实付</text>
|
||||||
<text class="amount-value">{{ formatAmount(item.actual_amount || item.total_amount) }}</text>
|
<text class="amount-value">{{ getAmountText(item) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 快捷操作 -->
|
<!-- 快捷操作 -->
|
||||||
<view class="order-actions" v-if="currentTab === 'pending'">
|
<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>
|
<button class="action-btn primary" @tap.stop="payOrder(item)">立即支付</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -124,7 +128,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
|
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 currentTab = ref('pending')
|
||||||
const orders = ref([])
|
const orders = ref([])
|
||||||
@ -159,7 +164,10 @@ function formatTime(t) {
|
|||||||
return `${m}-${day} ${hh}:${mm}`
|
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'
|
if (a === undefined || a === null) return '¥0.00'
|
||||||
const n = Number(a)
|
const n = Number(a)
|
||||||
if (Number.isNaN(n)) return '¥0.00'
|
if (Number.isNaN(n)) return '¥0.00'
|
||||||
@ -167,20 +175,46 @@ function formatAmount(a) {
|
|||||||
return `¥${yuan.toFixed(2)}`
|
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) {
|
function getOrderTitle(item) {
|
||||||
// 优先使用 remark 中的商品名称
|
// 1. 优先使用 items 中的商品名称(通常是实物购买或中奖)
|
||||||
if (item.remark && !item.remark.startsWith('lottery:')) {
|
if (item.items && item.items.length > 0 && item.items[0].title) {
|
||||||
return item.remark
|
return item.items[0].title
|
||||||
}
|
}
|
||||||
// 其次使用 items 中的商品名称
|
|
||||||
if (item.items && item.items.length > 0) {
|
// 2. 其次使用活动名称(玩法类订单)
|
||||||
return item.items[0].title || '商品'
|
|
||||||
}
|
|
||||||
// 使用活动名称
|
|
||||||
if (item.activity_name) {
|
if (item.activity_name) {
|
||||||
return 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) {
|
function getProductImage(item) {
|
||||||
@ -205,14 +239,28 @@ function getProductImage(item) {
|
|||||||
|
|
||||||
function getTypeIcon(item) {
|
function getTypeIcon(item) {
|
||||||
const sourceType = item.source_type
|
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 '🛒' // 商城订单
|
if (sourceType === 1) return '🛒' // 商城订单
|
||||||
return '📦'
|
return '📦'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTypeName(item) {
|
function getTypeName(item) {
|
||||||
const sourceType = item.source_type
|
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 '商城'
|
if (sourceType === 1) return '商城'
|
||||||
return '订单'
|
return '订单'
|
||||||
}
|
}
|
||||||
@ -242,26 +290,30 @@ function getStatusClass(item) {
|
|||||||
|
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
if (currentTab.value === tab) return
|
if (currentTab.value === tab) return
|
||||||
|
vibrateShort()
|
||||||
currentTab.value = tab
|
currentTab.value = tab
|
||||||
fetchOrders(false)
|
fetchOrders(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiStatus() {
|
function apiStatus() {
|
||||||
return currentTab.value === 'pending' ? 'pending' : 'completed'
|
// 1: 待付款, 2: 已完成
|
||||||
|
return currentTab.value === 'pending' ? 1 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤掉 source_type=3 的发奖订单
|
// 过滤掉 source_type=3 的发奖订单
|
||||||
function filterOrders(items) {
|
function filterOrders(items) {
|
||||||
if (!Array.isArray(items)) return []
|
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) {
|
async function fetchOrders(append) {
|
||||||
const user_id = uni.getStorageSync('user_id')
|
const user_id = uni.getStorageSync('user_id')
|
||||||
const token = uni.getStorageSync('token')
|
const token = uni.getStorageSync('token')
|
||||||
const phoneBound = !!uni.getStorageSync('phone_bound')
|
// 使用统一的手机号绑定检查
|
||||||
|
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
|
||||||
|
|
||||||
if (!user_id || !token || !phoneBound) {
|
if (!user_id || !token || !hasPhoneBound) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
content: '请先登录并绑定手机号',
|
content: '请先登录并绑定手机号',
|
||||||
@ -349,12 +401,18 @@ async function fetchAllOrders() {
|
|||||||
function goOrderDetail(item) {
|
function goOrderDetail(item) {
|
||||||
// 跳转订单详情页
|
// 跳转订单详情页
|
||||||
uni.navigateTo({
|
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() {
|
function goShopping() {
|
||||||
|
// #ifdef MP-TOUTIAO
|
||||||
|
// 抖音平台跳转到商城
|
||||||
|
uni.switchTab({ url: '/pages/shop/index' })
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-TOUTIAO
|
||||||
uni.switchTab({ url: '/pages/index/index' })
|
uni.switchTab({ url: '/pages/index/index' })
|
||||||
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doCancelOrder(item) {
|
async function doCancelOrder(item) {
|
||||||
@ -379,9 +437,65 @@ async function doCancelOrder(item) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function payOrder(item) {
|
async function payOrder(item) {
|
||||||
// TODO: 跳转支付
|
const openid = uni.getStorageSync('openid')
|
||||||
uni.showToast({ title: '支付功能开发中', icon: 'none' })
|
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) => {
|
onLoad((opts) => {
|
||||||
@ -397,30 +511,34 @@ onReachBottom(() => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* ============================================
|
/* ============================================
|
||||||
订单页面 - 高级设计重构
|
柯大鸭潮玩 - 订单页面
|
||||||
|
采用暖橙色调的订单列表设计
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.page-container {
|
.page-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg-page;
|
background: $bg-page;
|
||||||
position: relative;
|
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 {
|
.tabs {
|
||||||
|
@extend .glass-card;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
background: rgba($bg-card, 0.95);
|
|
||||||
backdrop-filter: blur(20rpx);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 100;
|
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 {
|
.tab-item {
|
||||||
@ -570,16 +688,19 @@ onReachBottom(() => {
|
|||||||
|
|
||||||
/* 订单卡片 */
|
/* 订单卡片 */
|
||||||
.order-card {
|
.order-card {
|
||||||
background: $bg-card;
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(10rpx);
|
||||||
border-radius: $radius-xl;
|
border-radius: $radius-xl;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: $shadow-card;
|
box-shadow: $shadow-sm;
|
||||||
animation: fadeInUp 0.4s ease-out backwards;
|
animation: fadeInUp 0.4s ease-out backwards;
|
||||||
animation-delay: var(--delay, 0s);
|
animation-delay: var(--delay, 0s);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -698,6 +819,14 @@ onReachBottom(() => {
|
|||||||
background: $bg-secondary;
|
background: $bg-secondary;
|
||||||
padding: 4rpx 12rpx;
|
padding: 4rpx 12rpx;
|
||||||
border-radius: $radius-sm;
|
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 {
|
.order-time {
|
||||||
font-size: $font-xs;
|
font-size: $font-xs;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="wrap">
|
<view class="wrap">
|
||||||
<!-- 顶部装饰背景 -->
|
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||||
<view class="page-bg-decoration"></view>
|
<view class="bg-decoration"></view>
|
||||||
|
|
||||||
<view class="header-area">
|
<view class="header-area">
|
||||||
<view class="page-title">积分明细</view>
|
<view class="page-title">积分明细</view>
|
||||||
@ -26,14 +26,14 @@
|
|||||||
class="record-item"
|
class="record-item"
|
||||||
:style="{ animationDelay: `${index * 0.05}s` }"
|
:style="{ animationDelay: `${index * 0.05}s` }"
|
||||||
>
|
>
|
||||||
<view class="record-icon" :class="{ 'is-add': (item.change || item.amount || 0) > 0 }">
|
<view class="record-icon" :class="{ 'is-add': (item.points || 0) > 0 }">
|
||||||
{{ (item.change || item.amount || 0) > 0 ? '↓' : '↑' }}
|
{{ (item.points || 0) > 0 ? '↓' : '↑' }}
|
||||||
</view>
|
</view>
|
||||||
<view class="record-content">
|
<view class="record-content">
|
||||||
<view class="record-main">
|
<view class="record-main">
|
||||||
<view class="record-title">{{ item.title || item.reason || '积分变更' }}</view>
|
<view class="record-title">{{ getActionText(item.action) || item.title || item.reason || '积分变更' }}</view>
|
||||||
<view class="record-amount" :class="{ inc: (item.change || item.amount || 0) > 0, dec: (item.change || item.amount || 0) < 0 }">
|
<view class="record-amount" :class="{ inc: (item.points || 0) > 0, dec: (item.points || 0) < 0 }">
|
||||||
{{ (item.change ?? item.amount ?? 0) > 0 ? '+' : '' }}{{ item.change ?? item.amount ?? 0 }}
|
{{ (item.points ?? 0) > 0 ? '+' : '' }}{{ formatPoints(item.points) }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="record-footer">
|
<view class="record-footer">
|
||||||
@ -65,6 +65,11 @@ const error = ref('')
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const hasMore = ref(true)
|
const hasMore = ref(true)
|
||||||
|
function formatPoints(v) {
|
||||||
|
const n = Number(v) || 0
|
||||||
|
if (n === 0) return '0'
|
||||||
|
return n.toString()
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(t) {
|
function formatTime(t) {
|
||||||
if (!t) return ''
|
if (!t) return ''
|
||||||
@ -77,11 +82,32 @@ function formatTime(t) {
|
|||||||
return `${y}-${m}-${day} ${hh}:${mm}`
|
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) {
|
async function fetchRecords(append = false) {
|
||||||
const user_id = uni.getStorageSync('user_id')
|
const user_id = uni.getStorageSync('user_id')
|
||||||
const token = uni.getStorageSync('token')
|
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({
|
uni.showModal({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
content: '请先登录并绑定手机号',
|
content: '请先登录并绑定手机号',
|
||||||
@ -106,7 +132,7 @@ async function fetchRecords(append = false) {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const list = await getPointsRecords(user_id, page.value, pageSize.value)
|
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
|
const total = (list && list.total) || 0
|
||||||
if (append) {
|
if (append) {
|
||||||
records.value = records.value.concat(items)
|
records.value = records.value.concat(items)
|
||||||
@ -143,33 +169,27 @@ onReachBottom(() => {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: $bg-page;
|
background-color: $bg-page;
|
||||||
position: relative;
|
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 {
|
.header-area {
|
||||||
padding: $spacing-xl $spacing-lg;
|
padding: $spacing-xl $spacing-lg;
|
||||||
|
padding-top: calc(env(safe-area-inset-top) + 20rpx);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 48rpx;
|
font-size: 48rpx;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: $text-main;
|
color: $text-main;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: 8rpx;
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 1rpx;
|
||||||
|
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: $text-tertiary;
|
color: $text-tertiary;
|
||||||
196
pages-user/settings/index.vue
Normal file
196
pages-user/settings/index.vue
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<view class="settings-container">
|
||||||
|
<!-- 自定义 tabBar -->
|
||||||
|
<!-- #ifdef MP-TOUTIAO -->
|
||||||
|
<customTabBarToutiao />
|
||||||
|
<!-- #endif -->
|
||||||
|
<!-- #ifndef MP-TOUTIAO -->
|
||||||
|
<customTabBar />
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<!-- 顶部导航栏 -->
|
||||||
|
<!-- #ifndef MP-TOUTIAO -->
|
||||||
|
<view class="navbar">
|
||||||
|
<view class="navbar-content">
|
||||||
|
<text class="navbar-title">设置</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
|
<!-- 设置内容区域 -->
|
||||||
|
<view class="settings-content">
|
||||||
|
<!-- 退出登录按钮 -->
|
||||||
|
<view class="logout-section">
|
||||||
|
<view class="logout-btn" @click="handleLogout">
|
||||||
|
<view class="logout-icon">
|
||||||
|
<image class="logout-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNGRjQwNDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05IDIxSDVhMiAyIDAgMCAxLTItMnYtNWEyIDIgMCAwIDEgMi0yaDRtMCAwdjZtMC0wdjZtMC02aDZhMiAyIDAgMCAxIDIgMnY4YTIgMiAwIDAgMS0yIDJINyIgLz48cGF0aCBkPSJNOCAxM2w1LTU1IDU1LTUiIC8+PC9zdmc+" mode="aspectFit"></image>
|
||||||
|
</view>
|
||||||
|
<text class="logout-text">退出当前账号</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// #ifdef MP-TOUTIAO
|
||||||
|
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-TOUTIAO
|
||||||
|
import customTabBar from '@/components/app-tab-bar.vue'
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
// #ifdef MP-TOUTTAO
|
||||||
|
customTabBarToutiao
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-TOUTIAO
|
||||||
|
customTabBar
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
handleLogout() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '退出登录',
|
||||||
|
content: '确定要退出当前账号吗?退出后将清空本地缓存。',
|
||||||
|
confirmText: '确定退出',
|
||||||
|
confirmColor: '#FF6B00',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
this.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
try {
|
||||||
|
// 清空所有本地存储
|
||||||
|
uni.clearStorageSync()
|
||||||
|
|
||||||
|
// 显示提示
|
||||||
|
uni.showToast({
|
||||||
|
title: '已退出登录',
|
||||||
|
icon: 'success',
|
||||||
|
duration: 1500
|
||||||
|
})
|
||||||
|
|
||||||
|
// 延迟跳转到登录页
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.reLaunch({
|
||||||
|
url: '/pages/login/index'
|
||||||
|
})
|
||||||
|
}, 1500)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('退出登录失败:', e)
|
||||||
|
uni.showToast({
|
||||||
|
title: '退出失败,请重试',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.settings-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: $bg-page;
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom) + 120rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航栏 */
|
||||||
|
/* #ifndef MP-TOUTIAO */
|
||||||
|
.navbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(255,255,255,0.95));
|
||||||
|
backdrop-filter: blur(20rpx);
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
|
||||||
|
/* 设置内容区 */
|
||||||
|
/* #ifdef MP-TOUTIAO */
|
||||||
|
.settings-content {
|
||||||
|
padding-top: $spacing-lg;
|
||||||
|
padding-left: $spacing-lg;
|
||||||
|
padding-right: $spacing-lg;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
/* #ifndef MP-TOUTIAO */
|
||||||
|
.settings-content {
|
||||||
|
padding-top: calc(env(safe-area-inset-top) + 88rpx + $spacing-lg);
|
||||||
|
padding-left: $spacing-lg;
|
||||||
|
padding-right: $spacing-lg;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
|
||||||
|
/* 退出登录区域 */
|
||||||
|
.logout-section {
|
||||||
|
background: $bg-card;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: $uni-bg-color-hover;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-icon-img {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FF4040;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
857
pages-user/tasks/index.vue
Normal file
857
pages-user/tasks/index.vue
Normal file
@ -0,0 +1,857 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page-container">
|
||||||
|
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||||
|
<view class="bg-decoration"></view>
|
||||||
|
|
||||||
|
<view class="header-area">
|
||||||
|
<view class="page-title">任务中心</view>
|
||||||
|
<view class="page-subtitle">Task Center</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 进度统计卡片 - 毛玻璃风格 -->
|
||||||
|
<view class="progress-card glass-card">
|
||||||
|
<view class="progress-header">
|
||||||
|
<text class="progress-title">📊 我的任务进度</text>
|
||||||
|
</view>
|
||||||
|
<view class="progress-stats">
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-value highlight">{{ userProgress.orderCount || 0 }}</text>
|
||||||
|
<text class="stat-label">累计订单</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider"></view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-value highlight">{{ userProgress.inviteCount || 0 }}</text>
|
||||||
|
<text class="stat-label">邀请人数</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider"></view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<view class="stat-value first-order-check" :class="{ done: userProgress.firstOrder }">
|
||||||
|
{{ userProgress.firstOrder ? '✓' : '—' }}
|
||||||
|
</view>
|
||||||
|
<text class="stat-label">首单完成</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 任务列表 -->
|
||||||
|
<scroll-view
|
||||||
|
scroll-y
|
||||||
|
class="content-scroll"
|
||||||
|
refresher-enabled
|
||||||
|
:refresher-triggered="isRefreshing"
|
||||||
|
@refresherrefresh="onRefresh"
|
||||||
|
>
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading && tasks.length === 0" class="loading-state">
|
||||||
|
<view class="spinner"></view>
|
||||||
|
<text>加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else-if="tasks.length === 0" class="empty-state">
|
||||||
|
<text class="empty-icon">📝</text>
|
||||||
|
<text class="empty-text">暂无可用任务</text>
|
||||||
|
<text class="empty-hint">敬请期待更多精彩活动</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 任务卡片列表 -->
|
||||||
|
<view v-else class="task-list">
|
||||||
|
<view
|
||||||
|
v-for="(task, index) in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="task-card"
|
||||||
|
:style="{ animationDelay: `${index * 0.1}s` }"
|
||||||
|
>
|
||||||
|
<!-- 任务头部 -->
|
||||||
|
<view class="task-header" @click="toggleTask(task.id)">
|
||||||
|
<view class="task-info">
|
||||||
|
<text class="task-icon">{{ getTaskIcon(task) }}</text>
|
||||||
|
<view class="task-meta">
|
||||||
|
<text class="task-name">{{ task.name }}</text>
|
||||||
|
<text class="task-desc">{{ task.description }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="task-status-wrap">
|
||||||
|
<view class="task-status" :class="getTaskStatusClass(task)">
|
||||||
|
{{ getTaskStatusText(task) }}
|
||||||
|
</view>
|
||||||
|
<text class="expand-arrow" :class="{ expanded: expandedTasks[task.id] }">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 档位列表 (可展开) -->
|
||||||
|
<view class="tier-list" v-if="expandedTasks[task.id] && task.tiers && task.tiers.length > 0">
|
||||||
|
<view
|
||||||
|
v-for="tier in task.tiers"
|
||||||
|
:key="tier.id"
|
||||||
|
class="tier-item"
|
||||||
|
:class="{ 'tier-claimed': isTierClaimed(task.id, tier.id), 'tier-claimable': isTierClaimable(task, tier) }"
|
||||||
|
>
|
||||||
|
<view class="tier-left">
|
||||||
|
<view class="tier-condition">
|
||||||
|
<text class="tier-badge">{{ getTierBadge(tier) }}</text>
|
||||||
|
<text class="tier-text">{{ getTierConditionText(tier) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="tier-reward">
|
||||||
|
<text class="reward-icon">🎁</text>
|
||||||
|
<text class="reward-text">{{ getTierRewardText(task, tier) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="tier-right">
|
||||||
|
<!-- 已领取 -->
|
||||||
|
<view v-if="isTierClaimed(task.id, tier.id)" class="tier-btn claimed">
|
||||||
|
<text>已领取</text>
|
||||||
|
</view>
|
||||||
|
<!-- 可领取 -->
|
||||||
|
<view v-else-if="isTierClaimable(task, tier)" class="tier-btn claimable" @click="claimReward(task, tier)">
|
||||||
|
<text>{{ claiming[`${task.id}_${tier.id}`] ? '领取中...' : '领取' }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 进度中 -->
|
||||||
|
<view v-else class="tier-progress">
|
||||||
|
<text class="progress-text">{{ getTierProgressText(task, tier) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 无档位提示 -->
|
||||||
|
<view class="no-tier-hint" v-if="expandedTasks[task.id] && (!task.tiers || task.tiers.length === 0)">
|
||||||
|
<text>暂无可领取档位</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { getTasks, getTaskProgress, claimTaskReward } from '../../api/appUser'
|
||||||
|
import { vibrateShort } from '@/utils/vibrate.js'
|
||||||
|
|
||||||
|
const tasks = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const isRefreshing = ref(false)
|
||||||
|
const expandedTasks = reactive({})
|
||||||
|
const claiming = reactive({})
|
||||||
|
|
||||||
|
// 用户进度 (汇总 - 用于顶部统计卡片显示)
|
||||||
|
const userProgress = reactive({
|
||||||
|
orderCount: 0,
|
||||||
|
orderAmount: 0,
|
||||||
|
inviteCount: 0,
|
||||||
|
firstOrder: false,
|
||||||
|
claimedTiers: {} // { taskId: [tierId1, tierId2] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// BUG修复:每个任务独立存储进度数据
|
||||||
|
const taskProgress = reactive({}) // { taskId: { orderCount, orderAmount, inviteCount, firstOrder } }
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
function getUserId() {
|
||||||
|
return uni.getStorageSync('user_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查登录状态
|
||||||
|
function checkAuth() {
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
const userId = getUserId()
|
||||||
|
if (!token || !userId) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '请先登录',
|
||||||
|
confirmText: '去登录',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateTo({ url: '/pages/login/index' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取任务图标
|
||||||
|
function getTaskIcon(task) {
|
||||||
|
const name = (task.name || '').toLowerCase()
|
||||||
|
if (name.includes('首单') || name.includes('first')) return '🎁'
|
||||||
|
if (name.includes('订单') || name.includes('order')) return '📦'
|
||||||
|
if (name.includes('邀请') || name.includes('invite')) return '👥'
|
||||||
|
if (name.includes('签到') || name.includes('check')) return '📅'
|
||||||
|
if (name.includes('分享') || name.includes('share')) return '📣'
|
||||||
|
return '⭐'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取任务状态类
|
||||||
|
function getTaskStatusClass(task) {
|
||||||
|
const progress = userProgress.claimedTiers[task.id] || []
|
||||||
|
const allTiers = task.tiers || []
|
||||||
|
if (allTiers.length === 0) return 'status-waiting'
|
||||||
|
|
||||||
|
// 检查是否全部完成
|
||||||
|
const allClaimed = allTiers.every(t => progress.includes(t.id))
|
||||||
|
if (allClaimed) return 'status-done'
|
||||||
|
|
||||||
|
// 检查是否有可领取的
|
||||||
|
if (allTiers.some(t => isTierClaimable(task, t) && !progress.includes(t.id))) {
|
||||||
|
return 'status-claimable'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'status-progress'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取任务状态文字
|
||||||
|
function getTaskStatusText(task) {
|
||||||
|
const progress = userProgress.claimedTiers[task.id] || []
|
||||||
|
const allTiers = task.tiers || []
|
||||||
|
if (allTiers.length === 0) return '暂无档位'
|
||||||
|
|
||||||
|
const allClaimed = allTiers.every(t => progress.includes(t.id))
|
||||||
|
if (allClaimed) return '已完成'
|
||||||
|
|
||||||
|
if (allTiers.some(t => isTierClaimable(task, t) && !progress.includes(t.id))) {
|
||||||
|
return '可领取'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '进行中'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开/收起任务
|
||||||
|
function toggleTask(taskId) {
|
||||||
|
expandedTasks[taskId] = !expandedTasks[taskId]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取档位徽章
|
||||||
|
function getTierBadge(tier) {
|
||||||
|
const metric = tier.metric || ''
|
||||||
|
if (metric === 'first_order') return '首'
|
||||||
|
if (metric === 'order_count') return `${tier.threshold}单`
|
||||||
|
if (metric === 'order_amount') return `¥${tier.threshold / 100}`
|
||||||
|
if (metric === 'invite_count') return `${tier.threshold}人`
|
||||||
|
return tier.threshold || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取档位条件文字
|
||||||
|
function getTierConditionText(tier) {
|
||||||
|
const metric = tier.metric || ''
|
||||||
|
if (metric === 'first_order') return '完成首笔订单'
|
||||||
|
if (metric === 'order_count') return `累计下单 ${tier.threshold} 笔`
|
||||||
|
if (metric === 'order_amount') return `累计消费 ¥${tier.threshold / 100}`
|
||||||
|
if (metric === 'invite_count') return `邀请 ${tier.threshold} 位好友`
|
||||||
|
return `达成 ${tier.threshold}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取档位奖励文字
|
||||||
|
function getTierRewardText(task, tier) {
|
||||||
|
const rewards = (task.rewards || []).filter(r => r.tier_id === tier.id)
|
||||||
|
if (rewards.length === 0) return '神秘奖励'
|
||||||
|
|
||||||
|
const texts = rewards.map(r => {
|
||||||
|
const type = r.reward_type || ''
|
||||||
|
const name = r.reward_name || ''
|
||||||
|
const payload = r.reward_payload || {}
|
||||||
|
const qty = r.quantity || 1
|
||||||
|
|
||||||
|
// 优先使用后端返回的 reward_name
|
||||||
|
if (name) {
|
||||||
|
if (type === 'points') {
|
||||||
|
const points = payload.points || qty
|
||||||
|
return points > 1 ? `${points}${name}` : name
|
||||||
|
}
|
||||||
|
if (type === 'coupon') {
|
||||||
|
const value = payload.value || payload.amount
|
||||||
|
return value ? `${name}(¥${value / 100})` : name
|
||||||
|
}
|
||||||
|
return qty > 1 ? `${name}×${qty}` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:从 payload 解析
|
||||||
|
if (type === 'points') {
|
||||||
|
const value = payload.points || payload.value || payload.amount || qty
|
||||||
|
return `${value}积分`
|
||||||
|
}
|
||||||
|
if (type === 'coupon') {
|
||||||
|
const value = payload.value || payload.amount
|
||||||
|
return value ? `¥${value / 100}优惠券` : '优惠券'
|
||||||
|
}
|
||||||
|
if (type === 'item_card') {
|
||||||
|
return payload.name || '道具卡'
|
||||||
|
}
|
||||||
|
if (type === 'title') {
|
||||||
|
return payload.name || '专属称号'
|
||||||
|
}
|
||||||
|
if (type === 'game_ticket') {
|
||||||
|
return payload.game_code ? `${payload.amount || 1}张抽奖券` : '抽奖券'
|
||||||
|
}
|
||||||
|
return '奖励'
|
||||||
|
})
|
||||||
|
|
||||||
|
return texts.join(' + ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否已领取
|
||||||
|
function isTierClaimed(taskId, tierId) {
|
||||||
|
const claimed = userProgress.claimedTiers[taskId] || []
|
||||||
|
return claimed.includes(tierId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否可领取 - BUG修复:使用任务独立的进度数据
|
||||||
|
function isTierClaimable(task, tier) {
|
||||||
|
const metric = tier.metric || ''
|
||||||
|
const threshold = tier.threshold || 0
|
||||||
|
const operator = tier.operator || '>='
|
||||||
|
|
||||||
|
// 获取该任务独立的进度数据
|
||||||
|
const progress = taskProgress[task.id] || {}
|
||||||
|
|
||||||
|
let current = 0
|
||||||
|
if (metric === 'first_order') {
|
||||||
|
return progress.firstOrder || false
|
||||||
|
} else if (metric === 'order_count') {
|
||||||
|
current = progress.orderCount || 0
|
||||||
|
} else if (metric === 'order_amount') {
|
||||||
|
current = progress.orderAmount || 0
|
||||||
|
} else if (metric === 'invite_count') {
|
||||||
|
current = progress.inviteCount || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === '>=') return current >= threshold
|
||||||
|
if (operator === '==') return current === threshold
|
||||||
|
if (operator === '>') return current > threshold
|
||||||
|
return current >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取进度文字 - BUG修复:使用任务独立的进度数据
|
||||||
|
function getTierProgressText(task, tier) {
|
||||||
|
const metric = tier.metric || ''
|
||||||
|
const threshold = tier.threshold || 0
|
||||||
|
|
||||||
|
// 获取该任务独立的进度数据
|
||||||
|
const progress = taskProgress[task.id] || {}
|
||||||
|
|
||||||
|
let current = 0
|
||||||
|
if (metric === 'first_order') {
|
||||||
|
return progress.firstOrder ? '已完成' : '未完成'
|
||||||
|
} else if (metric === 'order_count') {
|
||||||
|
current = progress.orderCount || 0
|
||||||
|
} else if (metric === 'order_amount') {
|
||||||
|
current = progress.orderAmount || 0
|
||||||
|
return `¥${current / 100}/¥${threshold / 100}`
|
||||||
|
} else if (metric === 'invite_count') {
|
||||||
|
current = progress.inviteCount || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${current}/${threshold}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 领取奖励
|
||||||
|
async function claimReward(task, tier) {
|
||||||
|
const key = `${task.id}_${tier.id}`
|
||||||
|
if (claiming[key]) return
|
||||||
|
|
||||||
|
vibrateShort()
|
||||||
|
claiming[key] = true
|
||||||
|
try {
|
||||||
|
const userId = getUserId()
|
||||||
|
await claimTaskReward(task.id, userId, tier.id)
|
||||||
|
|
||||||
|
// 更新本地状态
|
||||||
|
if (!userProgress.claimedTiers[task.id]) {
|
||||||
|
userProgress.claimedTiers[task.id] = []
|
||||||
|
}
|
||||||
|
userProgress.claimedTiers[task.id].push(tier.id)
|
||||||
|
|
||||||
|
uni.showToast({ title: '领取成功!', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('领取失败:', e)
|
||||||
|
uni.showToast({ title: e.message || '领取失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
claiming[key] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
async function onRefresh() {
|
||||||
|
isRefreshing.value = true
|
||||||
|
await fetchData()
|
||||||
|
isRefreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据
|
||||||
|
async function fetchData() {
|
||||||
|
if (!checkAuth()) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const userId = getUserId()
|
||||||
|
|
||||||
|
// 获取任务列表
|
||||||
|
const res = await getTasks(1, 50)
|
||||||
|
const list = res.list || res.data || []
|
||||||
|
tasks.value = list
|
||||||
|
|
||||||
|
// 默认展开第一个任务
|
||||||
|
if (list.length > 0 && Object.keys(expandedTasks).length === 0) {
|
||||||
|
expandedTasks[list[0].id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户进度
|
||||||
|
if (list.length > 0) {
|
||||||
|
// 初始化汇总数据
|
||||||
|
userProgress.orderCount = 0
|
||||||
|
userProgress.orderAmount = 0
|
||||||
|
userProgress.inviteCount = 0
|
||||||
|
userProgress.firstOrder = false
|
||||||
|
userProgress.claimedTiers = {}
|
||||||
|
|
||||||
|
// 并行获取所有任务的进度
|
||||||
|
const progressPromises = list.map(t =>
|
||||||
|
getTaskProgress(t.id, userId).catch(err => {
|
||||||
|
console.warn(`[Tasks] 获取任务 ${t.id} 进度失败:`, err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const progressResults = await Promise.allSettled(progressPromises)
|
||||||
|
|
||||||
|
progressResults.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
const p = result.value
|
||||||
|
const taskId = list[index].id
|
||||||
|
|
||||||
|
// BUG修复:每个任务独立存储进度数据
|
||||||
|
taskProgress[taskId] = {
|
||||||
|
orderCount: p.order_count || 0,
|
||||||
|
orderAmount: p.order_amount || 0,
|
||||||
|
inviteCount: p.invite_count || 0,
|
||||||
|
firstOrder: p.first_order || false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聚合进度指标 (取各任务返回的最大值 - 仅用于顶部统计卡片显示)
|
||||||
|
userProgress.orderCount = Math.max(userProgress.orderCount, p.order_count || 0)
|
||||||
|
userProgress.orderAmount = Math.max(userProgress.orderAmount, p.order_amount || 0)
|
||||||
|
userProgress.inviteCount = Math.max(userProgress.inviteCount, p.invite_count || 0)
|
||||||
|
if (p.first_order) userProgress.firstOrder = true
|
||||||
|
|
||||||
|
// 记录各任务已领取的档位
|
||||||
|
userProgress.claimedTiers[taskId] = p.claimed_tiers || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('[Tasks] 汇总后的进度数据:', userProgress)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取任务失败:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-page;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.header-area {
|
||||||
|
padding: $spacing-xl $spacing-lg;
|
||||||
|
padding-top: calc(env(safe-area-inset-top) + 20rpx);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: $text-main;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度统计卡片 */
|
||||||
|
.progress-card {
|
||||||
|
@extend .glass-card;
|
||||||
|
margin: 0 $spacing-lg $spacing-lg;
|
||||||
|
padding: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: $text-main;
|
||||||
|
font-family: 'DIN Alternate', sans-serif;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.highlight {
|
||||||
|
color: $brand-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.first-order-check {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
font-size: 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-tertiary;
|
||||||
|
|
||||||
|
&.done {
|
||||||
|
background: rgba($brand-primary, 0.1);
|
||||||
|
color: $brand-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 50rpx;
|
||||||
|
background: $border-color-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容滚动区 */
|
||||||
|
.content-scroll {
|
||||||
|
height: calc(100vh - 400rpx);
|
||||||
|
padding: 0 $spacing-lg $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 26rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 80rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 28rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 24rpx;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 任务列表 */
|
||||||
|
.task-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 任务卡片 */
|
||||||
|
.task-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
animation: fadeInUp 0.5s ease-out backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20rpx);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header {
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-icon {
|
||||||
|
font-size: 40rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-main;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 100rpx;
|
||||||
|
margin-right: 8rpx;
|
||||||
|
|
||||||
|
&.status-done {
|
||||||
|
background: rgba($uni-color-success, 0.1);
|
||||||
|
color: $uni-color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-claimable {
|
||||||
|
background: rgba($brand-primary, 0.1);
|
||||||
|
color: $brand-primary;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-progress {
|
||||||
|
background: rgba($brand-primary, 0.05);
|
||||||
|
color: $text-sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-waiting {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $text-tertiary;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 档位列表 */
|
||||||
|
.tier-list {
|
||||||
|
border-top: 1rpx solid $border-color-light;
|
||||||
|
padding: 16rpx 24rpx 24rpx;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
border: 1rpx solid $border-color-light;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tier-claimed {
|
||||||
|
background: #f5f5f5;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tier-claimable {
|
||||||
|
border-color: $brand-primary;
|
||||||
|
background: rgba($brand-primary, 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-left {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-condition {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-badge {
|
||||||
|
background: $text-main;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18rpx;
|
||||||
|
padding: 4rpx 10rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-main;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-reward {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-icon {
|
||||||
|
font-size: 20rpx;
|
||||||
|
margin-right: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $brand-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-right {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-btn {
|
||||||
|
padding: 10rpx 24rpx;
|
||||||
|
border-radius: 100rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.claimed {
|
||||||
|
background: #eee;
|
||||||
|
color: $text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.claimable {
|
||||||
|
background: $brand-primary;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.3);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-progress {
|
||||||
|
padding: 10rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
font-family: 'DIN Alternate', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-tier-hint {
|
||||||
|
padding: 30rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: $text-tertiary;
|
||||||
|
font-size: 24rpx;
|
||||||
|
background: #fafafa;
|
||||||
|
border-top: 1rpx solid $border-color-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载动画 */
|
||||||
|
.spinner {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
border: 3rpx solid $bg-secondary;
|
||||||
|
border-top-color: $text-tertiary;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
pages.json
234
pages.json
@ -3,7 +3,8 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/index/index",
|
"path": "pages/index/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "uni-app"
|
"navigationBarTitleText": "柯大鸭",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -18,16 +19,10 @@
|
|||||||
"navigationBarTitleText": "商城"
|
"navigationBarTitleText": "商城"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/shop/detail",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "商品详情"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/cabinet/index",
|
"path": "pages/cabinet/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "货柜"
|
"navigationBarTitleText": "盒柜"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -35,87 +30,206 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "我的"
|
"navigationBarTitleText": "我的"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "pages/points/index",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "积分记录"
|
|
||||||
}
|
}
|
||||||
},
|
],
|
||||||
|
"subPackages": [
|
||||||
{
|
{
|
||||||
"path": "pages/orders/index",
|
"root": "pages-activity",
|
||||||
"style": {
|
"pages": [
|
||||||
"navigationBarTitleText": "我的订单"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/address/index",
|
"path": "activity/yifanshang/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": {
|
"style": {
|
||||||
"navigationBarTitleText": "一番赏"
|
"navigationBarTitleText": "一番赏"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/activity/wuxianshang/index",
|
"path": "activity/wuxianshang/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "无限赏"
|
"navigationBarTitleText": "无限赏"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/activity/duiduipeng/index",
|
"path": "activity/duiduipeng/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "对对碰"
|
"navigationBarTitleText": "对对碰"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/activity/list/index",
|
"path": "activity/list/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "活动列表"
|
"navigationBarTitleText": "活动列表"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/activity/pata/index",
|
"path": "activity/pata/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "爬塔"
|
"navigationBarTitleText": "爬塔"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/register/register",
|
"root": "pages-user",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "points/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": ""
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "pages-shop",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "shop/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "商品详情"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "pages-game",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "game/minesweeper/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "default",
|
||||||
|
"navigationBarTitleText": "扫雷 game"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "game/minesweeper/play",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "default",
|
||||||
|
"navigationBarTitleText": "扫雷对战",
|
||||||
|
"disableScroll": true,
|
||||||
|
"mp-weixin": {
|
||||||
|
"disableSwipeBack": true,
|
||||||
|
"enablePullDownRefresh": false,
|
||||||
|
"disableShareMenu": true,
|
||||||
|
"disableScroll": true,
|
||||||
|
"disableScale": true
|
||||||
|
},
|
||||||
|
"h5": {
|
||||||
|
"titleNView": false
|
||||||
|
},
|
||||||
|
"app-plus": {
|
||||||
|
"bounce": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "game/minesweeper/room-list",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "default",
|
||||||
|
"navigationBarTitleText": "对战列表",
|
||||||
|
"disableScroll": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "game/webview",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "游戏挑战",
|
||||||
|
"navigationBarBackgroundColor": "#000000",
|
||||||
|
"navigationBarTextStyle": "white"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tabBar": {
|
"tabBar": {
|
||||||
|
"custom": true,
|
||||||
"color": "#7A7E83",
|
"color": "#7A7E83",
|
||||||
"selectedColor": "#007AFF",
|
"selectedColor": "#007AFF",
|
||||||
"backgroundColor": "#FFFFFF",
|
"backgroundColor": "#FFFFFF",
|
||||||
@ -135,7 +249,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pagePath": "pages/cabinet/index",
|
"pagePath": "pages/cabinet/index",
|
||||||
"text": "货柜",
|
"text": "盒柜",
|
||||||
"iconPath": "static/tab/box.png",
|
"iconPath": "static/tab/box.png",
|
||||||
"selectedIconPath": "static/tab/box_active.png"
|
"selectedIconPath": "static/tab/box_active.png"
|
||||||
},
|
},
|
||||||
@ -153,5 +267,11 @@
|
|||||||
"navigationBarBackgroundColor": "#F8F8F8",
|
"navigationBarBackgroundColor": "#F8F8F8",
|
||||||
"backgroundColor": "#F8F8F8"
|
"backgroundColor": "#F8F8F8"
|
||||||
},
|
},
|
||||||
|
"easycom": {
|
||||||
|
"autoscan": true,
|
||||||
|
"custom": {
|
||||||
|
"^BlessingAnimation": "@/components/BlessingAnimation.vue"
|
||||||
|
}
|
||||||
|
},
|
||||||
"uniIdRouter": {}
|
"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>
|
|
||||||
@ -1,7 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="wrap">
|
<view class="wrap">
|
||||||
|
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||||
|
<view class="bg-decoration"></view>
|
||||||
|
|
||||||
|
<!-- 自定义 tabBar -->
|
||||||
|
<!-- #ifdef MP-TOUTIAO -->
|
||||||
|
<customTabBarToutiao />
|
||||||
|
<!-- #endif -->
|
||||||
|
<!-- #ifndef MP-TOUTIAO -->
|
||||||
|
<customTabBar />
|
||||||
|
<!-- #endif -->
|
||||||
|
|
||||||
<!-- 顶部 Tab -->
|
<!-- 顶部 Tab -->
|
||||||
<view class="tabs">
|
<view class="tabs glass-card">
|
||||||
<view class="tab-item" :class="{ active: currentTab === 0 }" @tap="switchTab(0)">
|
<view class="tab-item" :class="{ active: currentTab === 0 }" @tap="switchTab(0)">
|
||||||
<text class="tab-text">待处理</text>
|
<text class="tab-text">待处理</text>
|
||||||
<text class="tab-count" v-if="aggregatedList.length > 0">({{ aggregatedList.length }})</text>
|
<text class="tab-count" v-if="aggregatedList.length > 0">({{ aggregatedList.length }})</text>
|
||||||
@ -35,6 +46,7 @@
|
|||||||
<text class="item-name">{{ item.name || '未命名道具' }}</text>
|
<text class="item-name">{{ item.name || '未命名道具' }}</text>
|
||||||
<text class="item-price" v-if="item.price">单价: ¥{{ item.price }}</text>
|
<text class="item-price" v-if="item.price">单价: ¥{{ item.price }}</text>
|
||||||
<view class="item-actions">
|
<view class="item-actions">
|
||||||
|
<text class="invite-btn" v-if="!item.selected" @tap.stop="onInvite(item)">邀请填写</text>
|
||||||
<text class="item-count" v-if="!item.selected">x{{ item.count || 1 }}</text>
|
<text class="item-count" v-if="!item.selected">x{{ item.count || 1 }}</text>
|
||||||
<view class="stepper" v-else @tap.stop>
|
<view class="stepper" v-else @tap.stop>
|
||||||
<text class="step-btn minus" @tap.stop="changeCount(item, -1)">-</text>
|
<text class="step-btn minus" @tap.stop="changeCount(item, -1)">-</text>
|
||||||
@ -50,7 +62,9 @@
|
|||||||
|
|
||||||
<!-- 底部操作栏 -->
|
<!-- 底部操作栏 -->
|
||||||
<view class="bottom-bar" v-if="hasSelected">
|
<view class="bottom-bar" v-if="hasSelected">
|
||||||
<view class="selected-info">已选 {{ totalSelectedCount }} 件</view>
|
<view class="selected-info">
|
||||||
|
<text>已选 {{ totalSelectedCount }} 件</text>
|
||||||
|
</view>
|
||||||
<view class="btn-group">
|
<view class="btn-group">
|
||||||
<button class="action-btn btn-ship" @tap="onShip">发货</button>
|
<button class="action-btn btn-ship" @tap="onShip">发货</button>
|
||||||
<button class="action-btn btn-redeem" @tap="onRedeem">兑换</button>
|
<button class="action-btn btn-redeem" @tap="onRedeem">兑换</button>
|
||||||
@ -73,9 +87,12 @@
|
|||||||
<text class="batch-no" v-if="item.batch_no">{{ item.batch_no }}</text>
|
<text class="batch-no" v-if="item.batch_no">{{ item.batch_no }}</text>
|
||||||
<view class="count-badge">{{ item.count }}件商品</view>
|
<view class="count-badge">{{ item.count }}件商品</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="shipment-actions">
|
||||||
<view class="shipment-status" :class="getStatusClass(item.status)">
|
<view class="shipment-status" :class="getStatusClass(item.status)">
|
||||||
{{ getStatusText(item.status) }}
|
{{ getStatusText(item.status) }}
|
||||||
</view>
|
</view>
|
||||||
|
<text class="shipment-cancel" v-if="Number(item.status) === 1 && item.batch_no" @tap="onCancelShipping(item)">撤销发货</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 商品缩略图列表 -->
|
<!-- 商品缩略图列表 -->
|
||||||
@ -126,21 +143,57 @@
|
|||||||
<view v-if="loading && shippedList.length > 0" class="loading-more">加载更多...</view>
|
<view v-if="loading && shippedList.length > 0" class="loading-more">加载更多...</view>
|
||||||
<view v-if="!hasMore && shippedList.length > 0" class="no-more">没有更多了</view>
|
<view v-if="!hasMore && shippedList.length > 0" class="no-more">没有更多了</view>
|
||||||
</block>
|
</block>
|
||||||
|
<!-- 分享弹窗 -->
|
||||||
|
<view class="share-mask" v-if="showSharePopup" @tap="showSharePopup = false" @touchmove.stop></view>
|
||||||
|
<view class="share-popup glass-card" :class="{ 'show': showSharePopup }">
|
||||||
|
<view class="share-header">
|
||||||
|
<text class="share-title">邀请好友填写地址</text>
|
||||||
|
<text class="share-close" @tap="showSharePopup = false">×</text>
|
||||||
|
</view>
|
||||||
|
<view class="share-body">
|
||||||
|
<view class="share-item-preview">
|
||||||
|
<image class="preview-img" :src="sharingItem.image" mode="aspectFit"></image>
|
||||||
|
<view class="preview-info">
|
||||||
|
<text class="preview-name">{{ sharingItem.name }}</text>
|
||||||
|
<text class="preview-desc">邀请好友填写地址后,该奖品将发货至好友手中,并认领归属于分享账号。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="share-actions">
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
|
<button class="action-btn share-card-btn" open-type="share">发送给微信好友</button>
|
||||||
|
<!-- #endif -->
|
||||||
|
<button class="action-btn copy-link-btn" @tap="onCopyShareLink">复制分享链接</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { onShow, onReachBottom } from '@dcloudio/uni-app'
|
import { onShow, onReachBottom, onShareAppMessage, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { getInventory, getProductDetail, redeemInventory, requestShipping, listAddresses, getShipments } from '@/api/appUser'
|
import { getInventory, getProductDetail, redeemInventory, requestShipping, cancelShipping, listAddresses, getShipments, createAddressShare } from '@/api/appUser'
|
||||||
|
import { vibrateShort } from '@/utils/vibrate.js'
|
||||||
|
import { checkPhoneBound, checkPhoneBoundSync } from '@/utils/checkPhone.js'
|
||||||
|
// #ifdef MP-TOUTIAO
|
||||||
|
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-TOUTIAO
|
||||||
|
import customTabBar from '@/components/app-tab-bar.vue'
|
||||||
|
// #endif
|
||||||
|
|
||||||
const currentTab = ref(0)
|
const currentTab = ref(0)
|
||||||
const aggregatedList = ref([])
|
const aggregatedList = ref([])
|
||||||
const shippedList = ref([])
|
const shippedList = ref([])
|
||||||
|
const showSharePopup = ref(false)
|
||||||
|
const sharingItem = ref({})
|
||||||
|
const currentShareToken = ref('')
|
||||||
|
const currentShortLink = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(100)
|
const pageSize = ref(100)
|
||||||
const hasMore = ref(true)
|
const hasMore = ref(true)
|
||||||
|
const productMetaCache = new Map()
|
||||||
|
|
||||||
const totalCount = computed(() => {
|
const totalCount = computed(() => {
|
||||||
return aggregatedList.value.reduce((sum, item) => sum + (item.count || 1), 0)
|
return aggregatedList.value.reduce((sum, item) => sum + (item.count || 1), 0)
|
||||||
@ -158,15 +211,42 @@ const isAllSelected = computed(() => {
|
|||||||
return aggregatedList.value.length > 0 && aggregatedList.value.every(item => item.selected)
|
return aggregatedList.value.length > 0 && aggregatedList.value.every(item => item.selected)
|
||||||
})
|
})
|
||||||
|
|
||||||
onShow(() => {
|
async function fetchProductMeta(productId) {
|
||||||
const token = uni.getStorageSync('token')
|
const key = String(productId || '').trim()
|
||||||
const phoneBound = !!uni.getStorageSync('phone_bound')
|
if (!key) return null
|
||||||
console.log('cabinet onShow token:', token, 'isLogin:', !!token, 'phoneBound:', phoneBound)
|
if (productMetaCache.has(key)) return productMetaCache.get(key)
|
||||||
|
const res = await getProductDetail(productId)
|
||||||
|
const p = res && (res.data ?? res.result ?? res)
|
||||||
|
const meta = {
|
||||||
|
price: null
|
||||||
|
}
|
||||||
|
const rawPrice = (p && (p.price_sale ?? p.price)) ?? (res && res.price)
|
||||||
|
if (rawPrice !== undefined && rawPrice !== null) {
|
||||||
|
const n = Number(rawPrice)
|
||||||
|
if (!Number.isNaN(n)) meta.price = n / 100
|
||||||
|
}
|
||||||
|
productMetaCache.set(key, meta)
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
if (!token || !phoneBound) {
|
onShow(() => {
|
||||||
|
// 检查手机号绑定状态(快速检查本地缓存)
|
||||||
|
if (!checkPhoneBoundSync()) return
|
||||||
|
|
||||||
|
// Check for external tab switch request
|
||||||
|
try {
|
||||||
|
const targetTab = uni.getStorageSync('cabinet_target_tab')
|
||||||
|
if (targetTab !== '' && targetTab !== null && targetTab !== undefined) {
|
||||||
|
currentTab.value = Number(targetTab)
|
||||||
|
uni.removeStorageSync('cabinet_target_tab')
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
if (!token) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
content: '请先登录并绑定手机号',
|
content: '请先登录',
|
||||||
confirmText: '去登录',
|
confirmText: '去登录',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
@ -186,7 +266,21 @@ onShow(() => {
|
|||||||
if (currentTab.value === 1) {
|
if (currentTab.value === 1) {
|
||||||
loadShipments(uid)
|
loadShipments(uid)
|
||||||
} else {
|
} else {
|
||||||
loadAllInventory(uid)
|
loadInventory(uid) // 改为只加载第一页,后续由 onReachBottom 触发
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
const uid = uni.getStorageSync("user_id")
|
||||||
|
page.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
// Reset lists
|
||||||
|
if (currentTab.value === 1) {
|
||||||
|
shippedList.value = []
|
||||||
|
loadShipments(uid).finally(() => uni.stopPullDownRefresh())
|
||||||
|
} else {
|
||||||
|
aggregatedList.value = []
|
||||||
|
loadInventory(uid).finally(() => uni.stopPullDownRefresh())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -202,6 +296,7 @@ onReachBottom(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function switchTab(index) {
|
function switchTab(index) {
|
||||||
|
if (loading.value) return // 防止切换过快导致并发加载冲突
|
||||||
currentTab.value = index
|
currentTab.value = index
|
||||||
// 切换时重新加载数据
|
// 切换时重新加载数据
|
||||||
page.value = 1
|
page.value = 1
|
||||||
@ -212,7 +307,7 @@ function switchTab(index) {
|
|||||||
if (currentTab.value === 1) {
|
if (currentTab.value === 1) {
|
||||||
loadShipments(uid)
|
loadShipments(uid)
|
||||||
} else {
|
} else {
|
||||||
loadAllInventory(uid)
|
loadInventory(uid) // 改为按需加载
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,7 +363,8 @@ function getStatusClass(status) {
|
|||||||
1: 'status-pending', // 待发货
|
1: 'status-pending', // 待发货
|
||||||
2: 'status-shipped', // 已发货
|
2: 'status-shipped', // 已发货
|
||||||
3: 'status-delivered', // 已签收
|
3: 'status-delivered', // 已签收
|
||||||
4: 'status-cancelled' // 已取消
|
4: 'status-abnormal', // 异常
|
||||||
|
5: 'status-cancelled' // 已取消
|
||||||
}
|
}
|
||||||
return statusMap[status] || 'status-pending'
|
return statusMap[status] || 'status-pending'
|
||||||
}
|
}
|
||||||
@ -278,7 +374,8 @@ function getStatusText(status) {
|
|||||||
1: '待发货',
|
1: '待发货',
|
||||||
2: '运输中',
|
2: '运输中',
|
||||||
3: '已签收',
|
3: '已签收',
|
||||||
4: '已取消'
|
4: '异常',
|
||||||
|
5: '已取消'
|
||||||
}
|
}
|
||||||
return statusMap[status] || '待发货'
|
return statusMap[status] || '待发货'
|
||||||
}
|
}
|
||||||
@ -349,7 +446,7 @@ async function loadShipments(uid) {
|
|||||||
|
|
||||||
const next = page.value === 1 ? mapped : [...shippedList.value, ...mapped]
|
const next = page.value === 1 ? mapped : [...shippedList.value, ...mapped]
|
||||||
shippedList.value = next
|
shippedList.value = next
|
||||||
if (list.length < pageSize.value || (page.value * pageSize.value >= total && total > 0)) { hasMore.value = false } else { page.value += 1 }
|
if (page.value * pageSize.value >= total && total > 0) { hasMore.value = false } else { page.value += 1 }
|
||||||
if (list.length === 0) { hasMore.value = false }
|
if (list.length === 0) { hasMore.value = false }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Load shipments error:', e)
|
console.error('Load shipments error:', e)
|
||||||
@ -363,7 +460,11 @@ async function loadInventory(uid) {
|
|||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getInventory(uid, page.value, pageSize.value)
|
const params = {}
|
||||||
|
if (currentTab.value === 0) {
|
||||||
|
params.status = 1
|
||||||
|
}
|
||||||
|
const res = await getInventory(uid, page.value, pageSize.value, params)
|
||||||
console.log('Inventory loaded:', res)
|
console.log('Inventory loaded:', res)
|
||||||
|
|
||||||
let list = []
|
let list = []
|
||||||
@ -380,137 +481,37 @@ async function loadInventory(uid) {
|
|||||||
total = res.length
|
total = res.length
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤 status=1 (正常) 或 status=3 (已使用/已发货/已兑换) 的物品
|
// 后端已经按 status 分页并聚合了,这里直接映射返回的 items
|
||||||
// status=1: 正常在背包
|
const nextList = page.value === 1 ? [] : (currentTab.value === 1 ? [...shippedList.value] : [...aggregatedList.value])
|
||||||
// status=3: 已处理(可能是已发货或已兑换积分)
|
|
||||||
const filteredList = list.filter(item => {
|
|
||||||
const s = Number(item.status)
|
|
||||||
return s === 1 || s === 3
|
|
||||||
})
|
|
||||||
|
|
||||||
// 调试日志:打印第一条数据以确认字段结构
|
list.forEach(item => {
|
||||||
if (filteredList.length > 0) {
|
let imageUrl = cleanUrl(item.product_images || item.image)
|
||||||
console.log('Debug Inventory Item:', filteredList[0])
|
const mappedItem = {
|
||||||
}
|
id: item.product_id,
|
||||||
|
original_ids: item.inventory_ids || [],
|
||||||
// 根据当前 Tab 过滤是否发货
|
name: (item.product_name || '未知商品').trim(),
|
||||||
const targetItems = filteredList.filter(item => {
|
|
||||||
// Tab 0: 待处理 (has_shipment 为 false 且 status=1)
|
|
||||||
// Tab 1: 已申请发货 (has_shipment 为 true)
|
|
||||||
|
|
||||||
// 注意:API 返回的 has_shipment 可能是布尔值 true/false,也可能是数字 1/0,或者是字符串 "true"/"false"
|
|
||||||
// 这里做一个宽容的判断
|
|
||||||
const isShipped = item.has_shipment === true || item.has_shipment === 1 || String(item.has_shipment) === 'true' || String(item.has_shipment) === '1'
|
|
||||||
|
|
||||||
if (currentTab.value === 1) {
|
|
||||||
// 已申请发货列表:必须是已发货状态
|
|
||||||
// 注意:有些记录 status=3 且 has_shipment=true 表示已发货
|
|
||||||
return isShipped
|
|
||||||
} else {
|
|
||||||
// 待处理列表:未发货且 status=1 (status=3 且未发货的可能是已兑换积分,不应显示在待处理)
|
|
||||||
return !isShipped && Number(item.status) === 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('Filtered list (status=1, tab=' + currentTab.value + '):', targetItems)
|
|
||||||
|
|
||||||
// 处理新数据
|
|
||||||
const newItems = targetItems.map(item => {
|
|
||||||
let imageUrl = ''
|
|
||||||
try {
|
|
||||||
let rawImg = item.product_images || item.image
|
|
||||||
if (rawImg && typeof rawImg === 'string') {
|
|
||||||
imageUrl = cleanUrl(rawImg)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Image parse error:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isShipped = item.has_shipment === true || item.has_shipment === 1 || String(item.has_shipment) === 'true' || String(item.has_shipment) === '1'
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.product_id || item.id, // 优先使用 product_id
|
|
||||||
original_ids: [item.id], // 初始化 id 数组
|
|
||||||
name: (item.product_name || item.name || '').trim(),
|
|
||||||
image: imageUrl,
|
image: imageUrl,
|
||||||
count: 1,
|
price: item.product_price ? item.product_price / 100 : null,
|
||||||
|
count: item.count || 0,
|
||||||
selected: false,
|
selected: false,
|
||||||
selectedCount: 1,
|
selectedCount: item.count || 0,
|
||||||
has_shipment: isShipped,
|
has_shipment: item.has_shipment,
|
||||||
updated_at: item.updated_at // 保留更新时间用于分组
|
updated_at: item.updated_at
|
||||||
}
|
}
|
||||||
|
nextList.push(mappedItem)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Mapped new items:', newItems.length)
|
console.log('Final list (tab=' + currentTab.value + '):', JSON.parse(JSON.stringify(nextList)))
|
||||||
|
|
||||||
// 正确的聚合逻辑:
|
|
||||||
// 1. 如果是第一页,直接基于 newItems 生成初始列表(带去重聚合)
|
|
||||||
// 2. 如果是后续页,将 newItems 聚合到现有列表中
|
|
||||||
|
|
||||||
// 深拷贝当前列表
|
|
||||||
let currentList = currentTab.value === 1 ? shippedList : aggregatedList
|
|
||||||
let next = page.value === 1 ? [] : [...currentList.value]
|
|
||||||
|
|
||||||
if (currentTab.value === 1) {
|
if (currentTab.value === 1) {
|
||||||
// 已发货列表:按 updated_at 分组展示
|
shippedList.value = nextList
|
||||||
// 这里我们实际上不按 ID 聚合,而是直接把新数据追加进去,
|
|
||||||
// 但为了 UI 展示,我们可以在前端通过 computed 或在这里预处理进行分组
|
|
||||||
// 为了保持与原有列表结构一致(flat list),我们这里暂时按照 updated_at + product_id 聚合
|
|
||||||
// 或者:既然用户要求按 updated_at 分组,可能希望看到的是“一次发货申请”作为一个卡片?
|
|
||||||
// 这里的实现逻辑是:如果 updated_at 和 product_id 都相同,则聚合数量;否则作为新条目
|
|
||||||
|
|
||||||
newItems.forEach(newItem => {
|
|
||||||
// 查找是否存在 updated_at 和 product_id 都相同的条目
|
|
||||||
// 注意:updated_at 可能是 ISO 字符串,比较前最好截取到秒或直接比较字符串
|
|
||||||
const existingItem = next.find(i =>
|
|
||||||
i.id == newItem.id &&
|
|
||||||
new Date(i.updated_at).getTime() === new Date(newItem.updated_at).getTime()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existingItem) {
|
|
||||||
existingItem.count += 1
|
|
||||||
if (Array.isArray(existingItem.original_ids)) {
|
|
||||||
existingItem.original_ids.push(...newItem.original_ids)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
next.push(newItem)
|
aggregatedList.value = nextList
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// 待处理列表:按 product_id (id) 聚合
|
|
||||||
newItems.forEach(newItem => {
|
|
||||||
if (!newItem.id) {
|
|
||||||
next.push(newItem)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingItem = next.find(i => i.id == newItem.id)
|
|
||||||
if (existingItem) {
|
|
||||||
existingItem.count += 1
|
|
||||||
if (Array.isArray(existingItem.original_ids)) {
|
|
||||||
existingItem.original_ids.push(...newItem.original_ids)
|
|
||||||
} else {
|
|
||||||
existingItem.original_ids = [...newItem.original_ids]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
next.push(newItem)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Final aggregated list:', JSON.parse(JSON.stringify(next)))
|
|
||||||
|
|
||||||
if (currentTab.value === 1) {
|
|
||||||
shippedList.value = next
|
|
||||||
} else {
|
|
||||||
aggregatedList.value = next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否还有更多
|
// 判断是否还有更多
|
||||||
// 注意:这里的 total 是总记录数(未过滤 status=1 之前的),
|
// 注意:这里的 total 是后端匹配过滤后的总记录数
|
||||||
// 我们的分页是基于原始数据的,所以判断依据是原始数据的分页进度
|
if ((page.value * pageSize.value >= total && total > 0) || list.length === 0) {
|
||||||
if (list.length < pageSize.value || (page.value * pageSize.value >= total && total > 0)) {
|
|
||||||
hasMore.value = false
|
hasMore.value = false
|
||||||
} else {
|
} else {
|
||||||
page.value += 1
|
page.value += 1
|
||||||
@ -529,50 +530,34 @@ async function loadInventory(uid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAllInventory(uid) {
|
|
||||||
try {
|
|
||||||
while (hasMore.value) {
|
|
||||||
await loadInventory(uid)
|
|
||||||
}
|
|
||||||
fetchProductPrices()
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProductPrices() {
|
|
||||||
const currentList = currentTab.value === 1 ? shippedList : aggregatedList
|
|
||||||
const list = currentList.value
|
|
||||||
for (let i = 0; i < list.length; i++) {
|
|
||||||
const item = list[i]
|
|
||||||
if (item.id && !item.price) {
|
|
||||||
try {
|
|
||||||
const res = await getProductDetail(item.id)
|
|
||||||
if (res && (res.price !== undefined || res.data?.price !== undefined)) {
|
|
||||||
// 优先取 res.price,其次 res.data.price (兼容不同返回结构)
|
|
||||||
const raw = res.price !== undefined ? res.price : res.data?.price
|
|
||||||
const num = Number(raw)
|
|
||||||
item.price = isNaN(num) ? null : (num / 100)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Fetch price failed for:', item.id, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelect(item) {
|
function toggleSelect(item) {
|
||||||
|
vibrateShort()
|
||||||
item.selected = !item.selected
|
item.selected = !item.selected
|
||||||
if (item.selected) {
|
if (item.selected) {
|
||||||
// 选中时默认数量为最大值
|
// 选中时默认数量为最大值
|
||||||
item.selectedCount = item.count
|
item.selectedCount = item.count
|
||||||
|
if (!item.price && item.id) {
|
||||||
|
fetchProductMeta(item.id).then(meta => {
|
||||||
|
if (!meta) return
|
||||||
|
if (!item.price && meta.price !== null) item.price = meta.price
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
|
vibrateShort()
|
||||||
const newState = !isAllSelected.value
|
const newState = !isAllSelected.value
|
||||||
aggregatedList.value.forEach(item => {
|
aggregatedList.value.forEach(item => {
|
||||||
item.selected = newState
|
item.selected = newState
|
||||||
if (newState) {
|
if (newState) {
|
||||||
item.selectedCount = item.count
|
item.selectedCount = item.count
|
||||||
|
if (!item.price && item.id) {
|
||||||
|
fetchProductMeta(item.id).then(meta => {
|
||||||
|
if (!meta) return
|
||||||
|
if (!item.price && meta.price !== null) item.price = meta.price
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -586,6 +571,7 @@ function changeCount(item, delta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onRedeem() {
|
async function onRedeem() {
|
||||||
|
vibrateShort()
|
||||||
const user_id = uni.getStorageSync('user_id')
|
const user_id = uni.getStorageSync('user_id')
|
||||||
if (!user_id) return
|
if (!user_id) return
|
||||||
|
|
||||||
@ -621,7 +607,7 @@ async function onRedeem() {
|
|||||||
aggregatedList.value = []
|
aggregatedList.value = []
|
||||||
page.value = 1
|
page.value = 1
|
||||||
hasMore.value = true
|
hasMore.value = true
|
||||||
loadAllInventory(user_id)
|
loadInventory(user_id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
|
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@ -633,6 +619,7 @@ async function onRedeem() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onShip() {
|
async function onShip() {
|
||||||
|
vibrateShort()
|
||||||
const user_id = uni.getStorageSync('user_id')
|
const user_id = uni.getStorageSync('user_id')
|
||||||
if (!user_id) return
|
if (!user_id) return
|
||||||
|
|
||||||
@ -653,7 +640,33 @@ async function onShip() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 确认发货
|
// 1. 先检查是否有默认地址
|
||||||
|
try {
|
||||||
|
const addresses = await listAddresses(user_id)
|
||||||
|
const addressList = addresses.list || addresses.data || addresses || []
|
||||||
|
|
||||||
|
if (!addressList || addressList.length === 0) {
|
||||||
|
// 没有默认地址,提示用户跳转到新建地址页面
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '申请发货需要设置默认地址,是否前往新建地址?',
|
||||||
|
confirmText: '前往',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateTo({ url: '/pages-user/address/edit' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取地址列表失败:', e)
|
||||||
|
uni.showToast({ title: '获取地址失败', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 有默认地址,确认发货
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '确认发货',
|
title: '确认发货',
|
||||||
content: `共 ${allIds.length} 件物品,确认申请发货?`,
|
content: `共 ${allIds.length} 件物品,确认申请发货?`,
|
||||||
@ -668,7 +681,7 @@ async function onShip() {
|
|||||||
aggregatedList.value = []
|
aggregatedList.value = []
|
||||||
page.value = 1
|
page.value = 1
|
||||||
hasMore.value = true
|
hasMore.value = true
|
||||||
loadAllInventory(user_id)
|
loadInventory(user_id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uni.showToast({ title: e.message || '申请失败', icon: 'none' })
|
uni.showToast({ title: e.message || '申请失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@ -678,11 +691,100 @@ async function onShip() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCancelShipping(shipment) {
|
||||||
|
const user_id = uni.getStorageSync('user_id')
|
||||||
|
const batchNo = shipment && shipment.batch_no
|
||||||
|
if (!user_id || !batchNo) return
|
||||||
|
uni.showModal({
|
||||||
|
title: '撤销发货',
|
||||||
|
content: `确认不再发货,并撤销发货单 ${batchNo} 吗?`,
|
||||||
|
confirmText: '确认撤销',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await cancelShipping(user_id, batchNo)
|
||||||
|
uni.showToast({ title: '已撤销发货', icon: 'success' })
|
||||||
|
page.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
shippedList.value = []
|
||||||
|
await loadShipments(user_id)
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.message || '取消失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 微信分享逻辑
|
||||||
|
onShareAppMessage((res) => {
|
||||||
|
showSharePopup.value = false
|
||||||
|
return {
|
||||||
|
title: `送你一个好礼,快来填写地址领走吧!`,
|
||||||
|
path: `/pages-user/address/submit?token=${currentShareToken.value}`,
|
||||||
|
imageUrl: sharingItem.value.image || '/static/logo.png'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onInvite(item) {
|
||||||
|
vibrateShort()
|
||||||
|
const user_id = uni.getStorageSync('user_id')
|
||||||
|
if (!user_id) {
|
||||||
|
uni.navigateTo({ url: '/pages/login/index' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取第一个可用的 inventory id
|
||||||
|
const invId = item.original_ids && item.original_ids[0]
|
||||||
|
if (!invId) {
|
||||||
|
uni.showToast({ title: '无效的资产', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showLoading({ title: '准备分享...' })
|
||||||
|
try {
|
||||||
|
const res = await createAddressShare(user_id, invId)
|
||||||
|
// 兼容不同的数据返回格式
|
||||||
|
currentShareToken.value = res.data?.share_token || res.share_token
|
||||||
|
currentShortLink.value = res.data?.short_link || res.short_link || ''
|
||||||
|
|
||||||
|
// 准备分享预览数据
|
||||||
|
sharingItem.value = {
|
||||||
|
id: invId,
|
||||||
|
name: item.name,
|
||||||
|
image: item.image,
|
||||||
|
count: item.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示分享弹窗
|
||||||
|
showSharePopup.value = true
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || '生成失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCopyShareLink() {
|
||||||
|
let url = currentShortLink.value
|
||||||
|
if (!url) {
|
||||||
|
url = `${window?.location?.origin || ''}/pages-user/address/submit?token=${currentShareToken.value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: url,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '已复制链接', icon: 'success' })
|
||||||
|
showSharePopup.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* ============================================
|
/* ============================================
|
||||||
奇盒潮玩 - 货柜页面
|
柯大鸭潮玩 - 盒柜页面
|
||||||
采用现代卡片式布局,统一设计语言
|
采用现代卡片式布局,统一设计语言
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@ -693,25 +795,28 @@ async function onShip() {
|
|||||||
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
|
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部 Tab */
|
/* 顶部 Tab */
|
||||||
.tabs {
|
.tabs {
|
||||||
|
@extend .glass-card;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
background: rgba($bg-card, 0.9);
|
|
||||||
backdrop-filter: blur(20rpx);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
box-shadow: $shadow-sm;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
@ -887,9 +992,22 @@ async function onShip() {
|
|||||||
.item-actions {
|
.item-actions {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.invite-btn {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $brand-primary;
|
||||||
|
background: rgba($brand-primary, 0.1);
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
background: rgba($brand-primary, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
.item-count {
|
.item-count {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: $text-main;
|
color: $text-main;
|
||||||
@ -940,7 +1058,7 @@ async function onShip() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
|
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
|
||||||
z-index: 99;
|
z-index: 1000; /* 提高z-index,确保在tabbar(999)上方 */
|
||||||
height: auto; /* reset old height */
|
height: auto; /* reset old height */
|
||||||
animation: slideUp 0.3s ease-out;
|
animation: slideUp 0.3s ease-out;
|
||||||
}
|
}
|
||||||
@ -948,6 +1066,9 @@ async function onShip() {
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: $text-main;
|
color: $text-main;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
}
|
}
|
||||||
.btn-group {
|
.btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1005,6 +1126,12 @@ async function onShip() {
|
|||||||
padding-bottom: 20rpx;
|
padding-bottom: 20rpx;
|
||||||
border-bottom: 1rpx solid rgba(0,0,0,0.05);
|
border-bottom: 1rpx solid rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
.shipment-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
.shipment-batch {
|
.shipment-batch {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1038,6 +1165,13 @@ async function onShip() {
|
|||||||
&.status-delivered { background: #F6FFED; color: #52C41A; }
|
&.status-delivered { background: #F6FFED; color: #52C41A; }
|
||||||
&.status-cancelled { background: #F5F5F5; color: #999; }
|
&.status-cancelled { background: #F5F5F5; color: #999; }
|
||||||
}
|
}
|
||||||
|
.shipment-cancel {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
padding: 6rpx 14rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.product-thumbnails {
|
.product-thumbnails {
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
@ -1148,12 +1282,124 @@ async function onShip() {
|
|||||||
from { opacity: 0; transform: translateY(20rpx); }
|
from { opacity: 0; transform: translateY(20rpx); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
@keyframes slideUp {
|
|
||||||
from { transform: translateY(100%); }
|
|
||||||
to { transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-spacer {
|
.bottom-spacer {
|
||||||
height: 120rpx;
|
height: 120rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 998;
|
||||||
|
backdrop-filter: blur(4rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-popup {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 40rpx 40rpx 0 0;
|
||||||
|
z-index: 999;
|
||||||
|
padding: 40rpx;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
|
||||||
|
.share-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-close {
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
padding: 10rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-item-preview {
|
||||||
|
display: flex;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 30rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
margin-bottom: 50rpx;
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.preview-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-main;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-desc {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-sub;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
height: 90rpx;
|
||||||
|
border-radius: 45rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&::after { border: none; }
|
||||||
|
|
||||||
|
&.share-card-btn {
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.copy-link-btn {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: $text-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1405
pages/mine/index.vue
1405
pages/mine/index.vue
File diff suppressed because it is too large
Load Diff
@ -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>
|
|
||||||
1106
pages/shop/index.vue
1106
pages/shop/index.vue
File diff suppressed because it is too large
Load Diff
BIN
static/logo.png
BIN
static/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 92 KiB |
BIN
static/share_invite.png
Normal file
BIN
static/share_invite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
94
uni.scss
94
uni.scss
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 奇盒潮玩 - 全局样式系统
|
* 柯大鸭潮玩 - 全局样式系统
|
||||||
*
|
*
|
||||||
* 基于潮玩盲盒风格的设计系统,采用暖橙渐变色调
|
* 基于潮玩盲盒风格的设计系统,采用暖橙渐变色调
|
||||||
* 传递惊喜、期待、活力的品牌调性
|
* 传递惊喜、期待、活力的品牌调性
|
||||||
@ -17,6 +17,7 @@ $brand-primary-dark: #E65100; // 深橙
|
|||||||
|
|
||||||
/* 辅助色 - 丰富视觉层次 */
|
/* 辅助色 - 丰富视觉层次 */
|
||||||
$accent-gold: #FFC107; // 质感金
|
$accent-gold: #FFC107; // 质感金
|
||||||
|
$accent-orange: #FF9500; // 活力橙
|
||||||
$accent-red: #FF3B30; // 促销红
|
$accent-red: #FF3B30; // 促销红
|
||||||
$accent-blue: #007AFF; // 科技蓝
|
$accent-blue: #007AFF; // 科技蓝
|
||||||
$accent-purple: #AF52DE; // 梦幻紫
|
$accent-purple: #AF52DE; // 梦幻紫
|
||||||
@ -183,3 +184,94 @@ $uni-font-size-paragraph: 15px;
|
|||||||
-webkit-line-clamp: $lines;
|
-webkit-line-clamp: $lines;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
💎 核心公共 UI 类 (Premium UI 6.0)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* 1. 统一背景装饰 - 漂浮光球 */
|
||||||
|
.bg-decoration {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -100rpx;
|
||||||
|
right: -100rpx;
|
||||||
|
width: 600rpx;
|
||||||
|
height: 600rpx;
|
||||||
|
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
|
||||||
|
filter: blur(60rpx);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.8;
|
||||||
|
animation: float 10s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 200rpx;
|
||||||
|
left: -200rpx;
|
||||||
|
width: 500rpx;
|
||||||
|
height: 500rpx;
|
||||||
|
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
|
||||||
|
filter: blur(50rpx);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 15s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 毛玻璃卡片基类 */
|
||||||
|
.glass-card {
|
||||||
|
background: $bg-glass;
|
||||||
|
backdrop-filter: blur(20rpx);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. 通用功能按钮 */
|
||||||
|
.btn-primary {
|
||||||
|
background: $gradient-brand;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: $shadow-warm;
|
||||||
|
transition: all 0.2s $ease-out;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: $text-main;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: $radius-round;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
transition: all 0.2s $ease-out;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
201
utils/activity.js
Normal file
201
utils/activity.js
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* 活动相关工具函数
|
||||||
|
* 从 yifanshang/duiduipeng/wuxianshang 页面中提取的公共逻辑
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解包API返回的数据
|
||||||
|
* @param {any} list - API返回的数据
|
||||||
|
* @returns {Array} 数组
|
||||||
|
*/
|
||||||
|
export function unwrap(list) {
|
||||||
|
if (Array.isArray(list)) return list
|
||||||
|
const obj = list || {}
|
||||||
|
const data = obj.data || {}
|
||||||
|
const arr = obj.list || obj.items || data.list || data.items || data
|
||||||
|
return Array.isArray(arr) ? arr : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断真值(支持多种格式)
|
||||||
|
* @param {any} v - 待判断的值
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function truthy(v) {
|
||||||
|
if (typeof v === 'boolean') return v
|
||||||
|
const s = String(v || '').trim().toLowerCase()
|
||||||
|
if (!s) return false
|
||||||
|
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测是否为BOSS奖
|
||||||
|
* @param {Object} item - 奖品对象
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function detectBoss(item) {
|
||||||
|
const i = item || {}
|
||||||
|
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 奖品等级映射 (与管理端保持一致)
|
||||||
|
*/
|
||||||
|
export const PRIZE_LEVEL_LABELS = {
|
||||||
|
1: 'S',
|
||||||
|
2: 'A',
|
||||||
|
3: 'B',
|
||||||
|
4: 'C',
|
||||||
|
5: 'D',
|
||||||
|
6: 'E',
|
||||||
|
7: 'F',
|
||||||
|
8: 'G',
|
||||||
|
9: 'H',
|
||||||
|
11: 'Last'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等级数字转字母/标签
|
||||||
|
* @param {number|string} level - 等级
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function levelToAlpha(level) {
|
||||||
|
if (level === 'BOSS') return 'BOSS'
|
||||||
|
const n = Number(level)
|
||||||
|
if (PRIZE_LEVEL_LABELS[n]) return PRIZE_LEVEL_LABELS[n]
|
||||||
|
if (isNaN(n) || n <= 0) return String(level || '赏')
|
||||||
|
// 兜底逻辑:如果超出定义的映射,使用 A, B, C...
|
||||||
|
return String.fromCharCode(64 + n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态转文本
|
||||||
|
* @param {number} status - 状态码
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function statusToText(status) {
|
||||||
|
if (status === 1) return '进行中'
|
||||||
|
if (status === 0) return '未开始'
|
||||||
|
if (status === 2) return '已结束'
|
||||||
|
return String(status || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化期列表数据
|
||||||
|
* @param {any} list - API返回的期列表
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function normalizeIssues(list) {
|
||||||
|
const arr = unwrap(list)
|
||||||
|
return arr.map((i, idx) => ({
|
||||||
|
id: i.id ?? String(idx),
|
||||||
|
title: i.title ?? i.name ?? '',
|
||||||
|
no: i.no ?? i.index ?? i.issue_no ?? i.issue_number ?? null,
|
||||||
|
status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '')
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化奖励列表数据
|
||||||
|
* @param {any} list - API返回的奖励列表
|
||||||
|
* @param {Function} cleanUrl - URL清理函数
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function normalizeRewards(list, cleanUrl = (u) => u) {
|
||||||
|
const arr = unwrap(list)
|
||||||
|
const items = arr.map((i, idx) => ({
|
||||||
|
id: i.product_id ?? i.id ?? String(idx),
|
||||||
|
title: i.name ?? i.title ?? '',
|
||||||
|
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
|
||||||
|
weight: Number(i.weight) || 0,
|
||||||
|
boss: detectBoss(i),
|
||||||
|
min_score: i.min_score || 0,
|
||||||
|
level: levelToAlpha(i.prize_level ?? i.level ?? (detectBoss(i) ? 'BOSS' : '赏'))
|
||||||
|
}))
|
||||||
|
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
|
||||||
|
const enriched = items.map(it => {
|
||||||
|
const rawPercent = total > 0 ? (it.weight / total) * 100 : 0
|
||||||
|
return {
|
||||||
|
...it,
|
||||||
|
percent: parseFloat(rawPercent.toFixed(2)) // 统一保留2位小数
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 按 weight 升序排列(从小到大)
|
||||||
|
enriched.sort((a, b) => (a.weight - b.weight))
|
||||||
|
return enriched
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找最新的期ID
|
||||||
|
* @param {Array} list - 期列表
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function pickLatestIssueId(list) {
|
||||||
|
const arr = Array.isArray(list) ? list : []
|
||||||
|
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
|
||||||
|
let maxNo = -Infinity
|
||||||
|
arr.forEach(i => {
|
||||||
|
const n = Number(i.no)
|
||||||
|
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
|
||||||
|
maxNo = n
|
||||||
|
latest = i.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return latest || (arr[0] && arr[0].id) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按等级分组奖励
|
||||||
|
* @param {Array} rewards - 奖励列表
|
||||||
|
* @param {string} playType - 活动类型 ('match', 'matching' 对对碰模式,其他为普通模式)
|
||||||
|
* @returns {Array} 分组后的奖励
|
||||||
|
*/
|
||||||
|
export function groupRewardsByLevel(rewards, playType = 'normal') {
|
||||||
|
const isMatchType = ['match', 'matching'].includes(playType)
|
||||||
|
|
||||||
|
const groups = {}
|
||||||
|
; (rewards || []).forEach(item => {
|
||||||
|
let level = item.level || '赏'
|
||||||
|
// 如果是对对碰(具有 min_score 且不是 BOSS),则组名包含对子数
|
||||||
|
if (item.min_score > 0 && level !== 'BOSS') {
|
||||||
|
level = `${item.min_score}对子`
|
||||||
|
}
|
||||||
|
if (!groups[level]) groups[level] = []
|
||||||
|
groups[level].push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.keys(groups).sort((a, b) => {
|
||||||
|
// Last 和 BOSS 优先(仅限普通模式)
|
||||||
|
if (!isMatchType) {
|
||||||
|
if (a === 'Last' || a === 'BOSS') return -1
|
||||||
|
if (b === 'Last' || b === 'BOSS') return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对对碰模式:按 min_score 升序排序
|
||||||
|
if (isMatchType) {
|
||||||
|
const extractScore = (key) => {
|
||||||
|
const match = key.match(/(\d+)对子/)
|
||||||
|
return match ? parseInt(match[1], 10) : 0
|
||||||
|
}
|
||||||
|
return extractScore(a) - extractScore(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通模式:分组之间按该组最小 weight 排序(升序)
|
||||||
|
const minWeightA = Math.min(...groups[a].map(item => item.weight || 0))
|
||||||
|
const minWeightB = Math.min(...groups[b].map(item => item.weight || 0))
|
||||||
|
return minWeightA - minWeightB
|
||||||
|
}).map(key => {
|
||||||
|
const levelRewards = groups[key]
|
||||||
|
// 对对碰模式:保持 min_score 升序(已在 previewRewards 排序)
|
||||||
|
// 普通模式:确保分组内的奖品按 weight 升序排列(从小到大)
|
||||||
|
if (!isMatchType) {
|
||||||
|
levelRewards.sort((a, b) => (a.weight - b.weight))
|
||||||
|
}
|
||||||
|
const total = levelRewards.reduce((sum, item) => sum + (Number(item.percent) || 0), 0)
|
||||||
|
return {
|
||||||
|
level: key,
|
||||||
|
rewards: levelRewards,
|
||||||
|
totalPercent: total.toFixed(2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
217
utils/cache.js
Normal file
217
utils/cache.js
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* 缓存管理工具
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REWARD_CACHE_KEY = 'reward_cache_v2' // v2: 修复概率精度问题
|
||||||
|
const MATCHING_GAME_CACHE_KEY = 'matching_game_cache_v1'
|
||||||
|
const MATCHING_GAME_TIMESTAMP_KEY = 'matching_game_last_timestamp' // 对对碰最后获取卡片数据的时间
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断缓存是否新鲜
|
||||||
|
* @param {number} timestamp - 缓存时间戳
|
||||||
|
* @param {number} ttl - 有效期(毫秒),默认24小时
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isFresh(timestamp, ttl = 24 * 60 * 60 * 1000) {
|
||||||
|
const now = Date.now()
|
||||||
|
const v = Number(timestamp || 0)
|
||||||
|
return now - v < ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取奖励缓存
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
export function getRewardCache() {
|
||||||
|
const obj = uni.getStorageSync(REWARD_CACHE_KEY) || {}
|
||||||
|
return typeof obj === 'object' && obj ? obj : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置奖励缓存
|
||||||
|
* @param {string} activityId - 活动ID
|
||||||
|
* @param {string} issueId - 期ID
|
||||||
|
* @param {any} value - 缓存值
|
||||||
|
*/
|
||||||
|
export function setRewardCache(activityId, issueId, value) {
|
||||||
|
const cache = getRewardCache()
|
||||||
|
const act = cache[activityId] || {}
|
||||||
|
act[issueId] = { value, ts: Date.now() }
|
||||||
|
cache[activityId] = act
|
||||||
|
uni.setStorageSync(REWARD_CACHE_KEY, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取奖励缓存项
|
||||||
|
* @param {string} activityId - 活动ID
|
||||||
|
* @param {string} issueId - 期ID
|
||||||
|
* @returns {any|null}
|
||||||
|
*/
|
||||||
|
export function getRewardCacheItem(activityId, issueId) {
|
||||||
|
const cache = getRewardCache()
|
||||||
|
const act = cache[activityId] || {}
|
||||||
|
const c = act[issueId]
|
||||||
|
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
|
||||||
|
return c.value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取对对碰游戏缓存
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
export function getMatchingGameCache() {
|
||||||
|
const obj = uni.getStorageSync(MATCHING_GAME_CACHE_KEY) || {}
|
||||||
|
return typeof obj === 'object' && obj ? obj : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取对对碰游戏缓存项
|
||||||
|
* @param {string} activityId - 活动ID
|
||||||
|
* @param {string} issueId - 期ID
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
export function readMatchingGameCacheEntry(activityId, issueId) {
|
||||||
|
const activityKey = String(activityId || '')
|
||||||
|
const issueKey = String(issueId || '')
|
||||||
|
if (!activityKey || !issueKey) return null
|
||||||
|
const cache = getMatchingGameCache()
|
||||||
|
const act = cache[activityKey] || {}
|
||||||
|
const entry = act && act[issueKey]
|
||||||
|
const ok = entry && typeof entry === 'object' && entry.game_id
|
||||||
|
return ok ? entry : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入对对碰游戏缓存项
|
||||||
|
* @param {string} activityId - 活动ID
|
||||||
|
* @param {string} issueId - 期ID
|
||||||
|
* @param {Object} entry - 缓存数据
|
||||||
|
*/
|
||||||
|
export function writeMatchingGameCacheEntry(activityId, issueId, entry) {
|
||||||
|
const activityKey = String(activityId || '')
|
||||||
|
const issueKey = String(issueId || '')
|
||||||
|
if (!activityKey || !issueKey) return
|
||||||
|
const cache = getMatchingGameCache()
|
||||||
|
|
||||||
|
// 清理超过170秒的缓存记录
|
||||||
|
const now = Date.now()
|
||||||
|
const TTL = 170 * 1000 // 170秒
|
||||||
|
|
||||||
|
// 遍历所有活动的所有期,删除过期的缓存
|
||||||
|
for (const actKey in cache) {
|
||||||
|
const act = cache[actKey]
|
||||||
|
if (!act || typeof act !== 'object') continue
|
||||||
|
|
||||||
|
for (const issKey in act) {
|
||||||
|
const entry = act[issKey]
|
||||||
|
if (!entry || typeof entry !== 'object') continue
|
||||||
|
|
||||||
|
const ts = Number(entry.ts || 0)
|
||||||
|
if (now - ts >= TTL) {
|
||||||
|
// 超过170秒,删除此缓存记录
|
||||||
|
delete act[issKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果该活动下没有任何期了,删除活动
|
||||||
|
if (Object.keys(act).length === 0) {
|
||||||
|
delete cache[actKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入新的缓存
|
||||||
|
const act = (cache[activityKey] && typeof cache[activityKey] === 'object') ? cache[activityKey] : {}
|
||||||
|
act[issueKey] = entry
|
||||||
|
cache[activityKey] = act
|
||||||
|
uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除对对碰游戏缓存项
|
||||||
|
* @param {string} activityId - 活动ID
|
||||||
|
* @param {string} issueId - 期ID
|
||||||
|
*/
|
||||||
|
export function clearMatchingGameCacheEntry(activityId, issueId) {
|
||||||
|
const activityKey = String(activityId || '')
|
||||||
|
const issueKey = String(issueId || '')
|
||||||
|
const cache = getMatchingGameCache()
|
||||||
|
const act = cache[activityKey]
|
||||||
|
if (!act || typeof act !== 'object') return
|
||||||
|
if (act[issueKey] !== undefined) delete act[issueKey]
|
||||||
|
if (Object.keys(act).length === 0) delete cache[activityKey]
|
||||||
|
else cache[activityKey] = act
|
||||||
|
uni.setStorageSync(MATCHING_GAME_CACHE_KEY, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找最新的对对碰游戏缓存
|
||||||
|
* @param {string} activityId - 活动ID
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
export function findLatestMatchingGameCacheEntry(activityId) {
|
||||||
|
const activityKey = String(activityId || '')
|
||||||
|
if (!activityKey) return null
|
||||||
|
const cache = getMatchingGameCache()
|
||||||
|
const act = cache[activityKey]
|
||||||
|
if (!act || typeof act !== 'object') return null
|
||||||
|
let bestIssueId = ''
|
||||||
|
let bestEntry = null
|
||||||
|
let bestTs = -Infinity
|
||||||
|
Object.keys(act).forEach(issueId => {
|
||||||
|
const entry = act[issueId]
|
||||||
|
if (!entry || typeof entry !== 'object' || !entry.game_id) return
|
||||||
|
const ts = Number(entry.ts || 0)
|
||||||
|
if (!bestEntry || ts > bestTs) {
|
||||||
|
bestTs = ts
|
||||||
|
bestIssueId = issueId
|
||||||
|
bestEntry = entry
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!bestEntry) return null
|
||||||
|
return { issue_id: bestIssueId, entry: bestEntry }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录对对碰游戏卡片获取时间戳
|
||||||
|
* 当成功调用 /api/app/matching/cards 接口时调用
|
||||||
|
*/
|
||||||
|
export function recordMatchingGameCardsTimestamp() {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
uni.setStorageSync(MATCHING_GAME_TIMESTAMP_KEY, timestamp)
|
||||||
|
console.log('[MatchingGame] 记录卡片获取时间戳:', new Date(timestamp).toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查对对碰游戏缓存是否过期
|
||||||
|
* @param {number} maxAgeSeconds - 最大有效期(秒),默认100秒
|
||||||
|
* @returns {boolean} true表示未过期,false表示已过期
|
||||||
|
*/
|
||||||
|
export function isMatchingGameCacheValid(maxAgeSeconds = 100) {
|
||||||
|
const timestamp = uni.getStorageSync(MATCHING_GAME_TIMESTAMP_KEY)
|
||||||
|
if (!timestamp) return false
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const ageSeconds = (now - timestamp) / 1000
|
||||||
|
const isValid = ageSeconds < maxAgeSeconds
|
||||||
|
|
||||||
|
console.log('[MatchingGame] 检查缓存有效期:', {
|
||||||
|
timestamp: new Date(timestamp).toISOString(),
|
||||||
|
now: new Date(now).toISOString(),
|
||||||
|
ageSeconds: ageSeconds.toFixed(2),
|
||||||
|
maxAgeSeconds,
|
||||||
|
isValid
|
||||||
|
})
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除对对碰游戏缓存和时间戳
|
||||||
|
*/
|
||||||
|
export function clearMatchingGameAllCache() {
|
||||||
|
uni.removeStorageSync(MATCHING_GAME_CACHE_KEY)
|
||||||
|
uni.removeStorageSync(MATCHING_GAME_TIMESTAMP_KEY)
|
||||||
|
console.log('[MatchingGame] 已清除所有对对碰缓存')
|
||||||
|
}
|
||||||
107
utils/checkPhone.js
Normal file
107
utils/checkPhone.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { getUserProfile } from '../api/appUser'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否已绑定手机号(同步,仅检查本地)
|
||||||
|
* @returns {boolean} 是否已绑定手机号
|
||||||
|
*/
|
||||||
|
export function hasPhoneBound() {
|
||||||
|
// 优先检查登录方式,如果是微信手机号登录或短信登录,则已绑定手机号
|
||||||
|
const loginMethod = uni.getStorageSync('login_method')
|
||||||
|
if (loginMethod === 'wechat_phone' || loginMethod === 'sms') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级检查 phone_number 缓存
|
||||||
|
const phoneNumber = uni.getStorageSync('phone_number') || ''
|
||||||
|
return !!phoneNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查手机号绑定状态
|
||||||
|
* 如果未绑定手机号,则跳转到登录页面进行绑定
|
||||||
|
* @returns {Promise<boolean>} 是否已绑定手机号
|
||||||
|
*/
|
||||||
|
export async function checkPhoneBound() {
|
||||||
|
try {
|
||||||
|
// 优先使用同步检查
|
||||||
|
if (hasPhoneBound()) {
|
||||||
|
console.log('[checkPhoneBound] 用户已通过手机号登录,跳过绑定检查')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用新的用户资料接口
|
||||||
|
const profile = await getUserProfile()
|
||||||
|
|
||||||
|
console.log('[checkPhoneBound] 用户资料:', profile)
|
||||||
|
|
||||||
|
// 检查是否已绑定手机号
|
||||||
|
const mobile = profile?.mobile
|
||||||
|
|
||||||
|
if (mobile) {
|
||||||
|
console.log('[checkPhoneBound] 已检测到手机号,允许通过:', mobile)
|
||||||
|
// 缓存手机号
|
||||||
|
uni.setStorageSync('phone_number', mobile)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未绑定手机号,显示提示并跳转
|
||||||
|
console.warn('[checkPhoneBound] 未检测到手机号,提示用户绑定')
|
||||||
|
uni.showModal({
|
||||||
|
title: '需要绑定手机号',
|
||||||
|
content: '为了账号安全,请先绑定手机号',
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '去绑定',
|
||||||
|
success: () => {
|
||||||
|
uni.navigateTo({ url: '/pages/login/index?mode=sms' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[checkPhoneBound] 获取用户信息失败:', err)
|
||||||
|
|
||||||
|
// 请求失败时,降级检查本地缓存
|
||||||
|
const phoneNumber = uni.getStorageSync('phone_number') || ''
|
||||||
|
console.log('[checkPhoneBound] 降级检查本地缓存:', phoneNumber ? phoneNumber : '未找到')
|
||||||
|
|
||||||
|
if (phoneNumber) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地也没有,提示重新登录
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '获取用户信息失败,请重新登录',
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '去登录',
|
||||||
|
success: () => {
|
||||||
|
uni.navigateTo({ url: '/pages/login/index' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步检查手机号绑定状态(仅检查本地缓存)
|
||||||
|
* @returns {boolean} 是否已绑定手机号
|
||||||
|
*/
|
||||||
|
export function checkPhoneBoundSync() {
|
||||||
|
if (hasPhoneBound()) {
|
||||||
|
console.log('[checkPhoneBoundSync] 用户已通过手机号登录,跳过绑定检查')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneNumber = uni.getStorageSync('phone_number') || ''
|
||||||
|
|
||||||
|
console.log('[checkPhoneBoundSync] 检查 phone_number 缓存:', phoneNumber ? phoneNumber : '未找到')
|
||||||
|
|
||||||
|
if (phoneNumber) {
|
||||||
|
console.log('[checkPhoneBoundSync] 已检测到手机号,允许通过:', phoneNumber)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[checkPhoneBoundSync] 未检测到手机号')
|
||||||
|
return false
|
||||||
|
}
|
||||||
76
utils/format.js
Normal file
76
utils/format.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 格式化工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理URL字符串
|
||||||
|
* @param {string} url - 原始URL
|
||||||
|
* @returns {string} 清理后的URL
|
||||||
|
*/
|
||||||
|
export function cleanUrl(url) {
|
||||||
|
const s = String(url || '').trim()
|
||||||
|
const m = s.match(/https?:\/\/[^\s'"`]+/)
|
||||||
|
if (m && m[0]) return m[0]
|
||||||
|
return s.replace(/[`'"]/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化百分比
|
||||||
|
* @param {number} value - 百分比值
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatPercent(value) {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n)) return '0%'
|
||||||
|
return `${n}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期时间
|
||||||
|
* @param {string|number|Date} value - 日期值
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatDateTime(value) {
|
||||||
|
const s = String(value || '').trim()
|
||||||
|
if (!s) return ''
|
||||||
|
const d = new Date(s)
|
||||||
|
if (Number.isNaN(d.getTime())) return s
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化价格(分转元)
|
||||||
|
* @param {number} cents - 分
|
||||||
|
* @param {number} decimals - 小数位数
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatPrice(cents, decimals = 2) {
|
||||||
|
const yuan = Number(cents || 0) / 100
|
||||||
|
return yuan.toFixed(decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析时间为毫秒戳
|
||||||
|
* @param {any} value - 时间值
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export function parseTimeMs(value) {
|
||||||
|
if (value === undefined || value === null || value === '') return null
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!Number.isFinite(value)) return null
|
||||||
|
return value < 1e12 ? value * 1000 : value
|
||||||
|
}
|
||||||
|
const s = String(value).trim()
|
||||||
|
if (!s) return null
|
||||||
|
const asNum = Number(s)
|
||||||
|
if (Number.isFinite(asNum)) return asNum < 1e12 ? asNum * 1000 : asNum
|
||||||
|
const d = new Date(s)
|
||||||
|
if (Number.isNaN(d.getTime())) return null
|
||||||
|
return d.getTime()
|
||||||
|
}
|
||||||
71
utils/nakama-adapter.js
Normal file
71
utils/nakama-adapter.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// Nakama SDK 小程序适配器
|
||||||
|
// 将 uni-app 的 connectSocket 和 Storage 适配到 Web 标准接口
|
||||||
|
|
||||||
|
export const WebSocketAdapter = {
|
||||||
|
build: function (url) {
|
||||||
|
const socketTask = uni.connectSocket({
|
||||||
|
url: url,
|
||||||
|
complete: () => { }
|
||||||
|
});
|
||||||
|
|
||||||
|
const webSocket = {
|
||||||
|
url: url,
|
||||||
|
readyState: 0, // CONNECTING
|
||||||
|
onopen: null,
|
||||||
|
onclose: null,
|
||||||
|
onerror: null,
|
||||||
|
onmessage: null,
|
||||||
|
send: (data) => {
|
||||||
|
socketTask.send({
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
socketTask.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socketTask.onOpen(() => {
|
||||||
|
webSocket.readyState = 1; // OPEN
|
||||||
|
if (webSocket.onopen) {
|
||||||
|
webSocket.onopen({ type: 'open' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socketTask.onClose((res) => {
|
||||||
|
webSocket.readyState = 3; // CLOSED
|
||||||
|
if (webSocket.onclose) {
|
||||||
|
webSocket.onclose({ code: res.code, reason: res.reason, wasClean: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socketTask.onError((err) => {
|
||||||
|
if (webSocket.onerror) {
|
||||||
|
webSocket.onerror({ error: err, message: err.errMsg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socketTask.onMessage((res) => {
|
||||||
|
if (webSocket.onmessage) {
|
||||||
|
webSocket.onmessage({ data: res.data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return webSocket;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const localStorageAdapter = {
|
||||||
|
getItem: (key) => {
|
||||||
|
return uni.getStorageSync(key);
|
||||||
|
},
|
||||||
|
setItem: (key, value) => {
|
||||||
|
uni.setStorageSync(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key) => {
|
||||||
|
uni.removeStorageSync(key);
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
uni.clearStorageSync();
|
||||||
|
}
|
||||||
|
};
|
||||||
5305
utils/nakama-js/nakama-js.js
Normal file
5305
utils/nakama-js/nakama-js.js
Normal file
File diff suppressed because it is too large
Load Diff
568
utils/nakamaManager.js
Normal file
568
utils/nakamaManager.js
Normal file
@ -0,0 +1,568 @@
|
|||||||
|
/**
|
||||||
|
* Nakama WebSocket Manager - 小程序直连版
|
||||||
|
* 移除 SDK 依赖,直接使用原生 WebSocket 协议
|
||||||
|
*/
|
||||||
|
|
||||||
|
class NakamaManager {
|
||||||
|
constructor() {
|
||||||
|
this.serverUrl = null;
|
||||||
|
this.serverKey = 'defaultkey';
|
||||||
|
this.useSSL = true;
|
||||||
|
this.host = null;
|
||||||
|
this.port = '443';
|
||||||
|
|
||||||
|
this.session = null;
|
||||||
|
this.gameToken = null;
|
||||||
|
this.socketTask = null;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.isConnecting = false; // 正在连接标志位
|
||||||
|
|
||||||
|
// 消息 ID 和待处理的 Promise
|
||||||
|
this.nextCid = 1;
|
||||||
|
this.pendingRequests = {};
|
||||||
|
|
||||||
|
// 事件监听器
|
||||||
|
this.listeners = {
|
||||||
|
onmatchmakermatched: null,
|
||||||
|
onmatchdata: null,
|
||||||
|
onmatchpresence: null,
|
||||||
|
ondisconnect: null,
|
||||||
|
onerror: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// 心跳定时器
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
this.heartbeatInterval = 10000; // 10秒
|
||||||
|
this.heartbeatId = 0; // 用于识别心跳版本的 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化客户端配置
|
||||||
|
*/
|
||||||
|
initClient(serverUrl, serverKey = 'defaultkey') {
|
||||||
|
this.serverKey = serverKey;
|
||||||
|
this.serverUrl = serverUrl;
|
||||||
|
|
||||||
|
// 解析 URL
|
||||||
|
const isWss = serverUrl.startsWith('wss://') || serverUrl.startsWith('https://');
|
||||||
|
let host = serverUrl.replace('wss://', '').replace('ws://', '').replace('https://', '').replace('http://', '');
|
||||||
|
let port = isWss ? '443' : '7350';
|
||||||
|
|
||||||
|
if (host.includes(':')) {
|
||||||
|
const parts = host.split(':');
|
||||||
|
host = parts[0];
|
||||||
|
port = parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.useSSL = isWss;
|
||||||
|
|
||||||
|
console.log(`[Nakama] Initialized: ${this.useSSL ? 'wss' : 'ws'}://${this.host}:${this.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置事件监听器
|
||||||
|
*/
|
||||||
|
setListeners(config) {
|
||||||
|
Object.keys(config).forEach(key => {
|
||||||
|
if (this.listeners.hasOwnProperty(key)) {
|
||||||
|
this.listeners[key] = config[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 game_token 认证
|
||||||
|
*/
|
||||||
|
async authenticateWithGameToken(gameToken, externalUserId = null) {
|
||||||
|
this.gameToken = gameToken;
|
||||||
|
|
||||||
|
let customId = externalUserId;
|
||||||
|
|
||||||
|
if (!customId) {
|
||||||
|
// 获取或生成持久化的 custom ID
|
||||||
|
customId = uni.getStorageSync('nakama_custom_id');
|
||||||
|
if (!customId) {
|
||||||
|
customId = `game_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||||
|
uni.setStorageSync('nakama_custom_id', customId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Nakama] Authenticating with ID:', customId, externalUserId ? '(Account-based)' : '(Device-based)');
|
||||||
|
|
||||||
|
// HTTP 认证请求
|
||||||
|
const scheme = this.useSSL ? 'https://' : 'http://';
|
||||||
|
const portSuffix = (this.useSSL && this.port === '443') || (!this.useSSL && this.port === '80') ? '' : `:${this.port}`;
|
||||||
|
const authUrl = `${scheme}${this.host}${portSuffix}/v2/account/authenticate/custom?create=true`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
url: authUrl,
|
||||||
|
method: 'POST',
|
||||||
|
header: {
|
||||||
|
'Authorization': 'Basic ' + this._base64Encode(`${this.serverKey}:`),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data: { id: customId },
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode === 200 && res.data && res.data.token) {
|
||||||
|
this.session = {
|
||||||
|
token: res.data.token,
|
||||||
|
refresh_token: res.data.refresh_token,
|
||||||
|
user_id: this._parseUserIdFromToken(res.data.token)
|
||||||
|
};
|
||||||
|
console.log('[Nakama] Authenticated, user_id:', this.session.user_id);
|
||||||
|
|
||||||
|
// 认证成功后建立 WebSocket 连接
|
||||||
|
this._connectWebSocket()
|
||||||
|
.then(() => resolve(this.session))
|
||||||
|
.catch(reject);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Authentication failed: ' + JSON.stringify(res.data)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
reject(new Error('Authentication request failed: ' + err.errMsg));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立 WebSocket 连接
|
||||||
|
*/
|
||||||
|
_connectWebSocket() {
|
||||||
|
if (this.isConnecting) {
|
||||||
|
console.log('[Nakama] Already connecting, skipping...');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 确保清理旧连接
|
||||||
|
if (this.socketTask) {
|
||||||
|
console.log('[Nakama] Closing existing socket before new connection');
|
||||||
|
try {
|
||||||
|
this.socketTask.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Nakama] Error closing old socket:', e);
|
||||||
|
}
|
||||||
|
this.socketTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnecting = true;
|
||||||
|
const scheme = this.useSSL ? 'wss://' : 'ws://';
|
||||||
|
const portSuffix = (this.useSSL && this.port === '443') || (!this.useSSL && this.port === '80') ? '' : `:${this.port}`;
|
||||||
|
const wsUrl = `${scheme}${this.host}${portSuffix}/ws?lang=en&status=true&token=${encodeURIComponent(this.session.token)}`;
|
||||||
|
|
||||||
|
console.log('[Nakama] WebSocket connecting...');
|
||||||
|
|
||||||
|
this.socketTask = uni.connectSocket({
|
||||||
|
url: wsUrl,
|
||||||
|
complete: () => { }
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectTimeout = setTimeout(() => {
|
||||||
|
reject(new Error('WebSocket connection timeout'));
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
this.socketTask.onOpen(() => {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
|
this.isConnected = true;
|
||||||
|
this.isConnecting = false;
|
||||||
|
console.log('[Nakama] WebSocket connected');
|
||||||
|
this._startHeartbeat();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socketTask.onClose((res) => {
|
||||||
|
console.log('[Nakama] WebSocket closed:', res.code, res.reason);
|
||||||
|
this.isConnected = false;
|
||||||
|
this._stopHeartbeat();
|
||||||
|
if (this.listeners.ondisconnect) {
|
||||||
|
this.listeners.ondisconnect(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socketTask.onError((err) => {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
|
console.error('[Nakama] WebSocket error:', err);
|
||||||
|
this.isConnected = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
if (this.listeners.onerror) {
|
||||||
|
this.listeners.onerror(err);
|
||||||
|
}
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socketTask.onMessage((res) => {
|
||||||
|
this._handleMessage(res.data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理收到的消息
|
||||||
|
*/
|
||||||
|
_handleMessage(rawData) {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Nakama] Failed to parse message:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有 cid 的消息是请求的响应
|
||||||
|
if (message.cid) {
|
||||||
|
const pending = this.pendingRequests[message.cid];
|
||||||
|
if (pending) {
|
||||||
|
delete this.pendingRequests[message.cid];
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
if (message.error) {
|
||||||
|
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
||||||
|
} else {
|
||||||
|
pending.resolve(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无 cid 的消息是服务器主动推送
|
||||||
|
if (message.matchmaker_matched) {
|
||||||
|
console.log('[Nakama] Matchmaker matched:', message.matchmaker_matched.match_id);
|
||||||
|
if (this.listeners.onmatchmakermatched) {
|
||||||
|
this.listeners.onmatchmakermatched(message.matchmaker_matched);
|
||||||
|
}
|
||||||
|
} else if (message.match_data) {
|
||||||
|
// 解码 base64 数据
|
||||||
|
if (message.match_data.data) {
|
||||||
|
// 原生 Base64 -> Unit8Array
|
||||||
|
const uint8arr = this._base64ToUint8Array(message.match_data.data);
|
||||||
|
// !!!关键修复:移除不可用的 TextDecoder,直接传输 Unit8Array,或在业务层处理
|
||||||
|
// 由于 play.vue 中还在使用 TextDecoder,这里我们需要提供一个方法来转字符串
|
||||||
|
// 为了兼容性,我们直接在这里转成字符串传出去,修改 onmatchdata 的约定
|
||||||
|
message.match_data.data = this._utf8Decode(uint8arr);
|
||||||
|
}
|
||||||
|
message.match_data.op_code = parseInt(message.match_data.op_code);
|
||||||
|
if (this.listeners.onmatchdata) {
|
||||||
|
this.listeners.onmatchdata(message.match_data);
|
||||||
|
}
|
||||||
|
} else if (message.match_presence_event) {
|
||||||
|
if (this.listeners.onmatchpresence) {
|
||||||
|
this.listeners.onmatchpresence(message.match_presence_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息并等待响应
|
||||||
|
*/
|
||||||
|
_send(message, timeoutMs = 10000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isConnected || !this.socketTask) {
|
||||||
|
reject(new Error('Socket not connected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cid = String(this.nextCid++);
|
||||||
|
message.cid = cid;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
delete this.pendingRequests[cid];
|
||||||
|
reject(new Error('Request timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
this.pendingRequests[cid] = { resolve, reject, timeout };
|
||||||
|
|
||||||
|
this.socketTask.send({
|
||||||
|
data: JSON.stringify(message),
|
||||||
|
fail: (err) => {
|
||||||
|
delete this.pendingRequests[cid];
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error('Send failed: ' + err.errMsg));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息(无需响应)
|
||||||
|
*/
|
||||||
|
_sendNoResponse(message) {
|
||||||
|
if (!this.isConnected || !this.socketTask) {
|
||||||
|
console.error('[Nakama] Cannot send, not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socketTask.send({
|
||||||
|
data: JSON.stringify(message),
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[Nakama] Send failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始匹配
|
||||||
|
*/
|
||||||
|
async findMatch(minCount, maxCount) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
console.log('[Nakama] Not connected, reconnecting...');
|
||||||
|
await this.authenticateWithGameToken(this.gameToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.gameToken) {
|
||||||
|
throw new Error('Missing game token in manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Nakama] Adding to matchmaker:', minCount, '-', maxCount);
|
||||||
|
const response = await this._send({
|
||||||
|
matchmaker_add: {
|
||||||
|
min_count: minCount || 2,
|
||||||
|
max_count: maxCount || 2,
|
||||||
|
query: '+properties.game_token:*',
|
||||||
|
string_properties: { game_token: this.gameToken }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('[Nakama] Matchmaker ticket:', response.matchmaker_ticket);
|
||||||
|
return response.matchmaker_ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入比赛
|
||||||
|
*/
|
||||||
|
async joinMatch(matchId, token) {
|
||||||
|
console.log('[Nakama] Joining match:', matchId);
|
||||||
|
const join = { match_join: {} };
|
||||||
|
if (token) {
|
||||||
|
join.match_join.token = token;
|
||||||
|
} else {
|
||||||
|
join.match_join.match_id = matchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键:传递 game_token 用于服务端验证
|
||||||
|
join.match_join.metadata = { game_token: this.gameToken };
|
||||||
|
|
||||||
|
const response = await this._send(join);
|
||||||
|
console.log('[Nakama] Joined match:', response.match?.match_id);
|
||||||
|
return response.match;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 RPC
|
||||||
|
*/
|
||||||
|
async rpc(id, payload) {
|
||||||
|
console.log('[Nakama] RPC call:', id);
|
||||||
|
const response = await this._send({
|
||||||
|
rpc: {
|
||||||
|
id: id,
|
||||||
|
payload: typeof payload === 'string' ? payload : JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Nakama] RPC response:', id, response);
|
||||||
|
|
||||||
|
if (response.rpc && response.rpc.payload) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(response.rpc.payload);
|
||||||
|
console.log('[Nakama] RPC parsed result:', id, parsed);
|
||||||
|
return parsed;
|
||||||
|
} catch (e) {
|
||||||
|
return response.rpc.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送游戏状态
|
||||||
|
*/
|
||||||
|
sendMatchState(matchId, opCode, data) {
|
||||||
|
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
|
const op = parseInt(opCode);
|
||||||
|
console.log(`[Nakama] Sending state: Match=${matchId}, OpCode=${op}`);
|
||||||
|
this._sendNoResponse({
|
||||||
|
match_data_send: {
|
||||||
|
match_id: matchId,
|
||||||
|
op_code: op,
|
||||||
|
data: this._base64Encode(payload)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开连接
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._stopHeartbeat();
|
||||||
|
if (this.socketTask) {
|
||||||
|
this.socketTask.close();
|
||||||
|
this.socketTask = null;
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
this.session = null;
|
||||||
|
// 注意:不要清空 gameToken,以便重连时仍然可以使用
|
||||||
|
// this.gameToken 只在 logout 或新 authenticate 时才会被更新
|
||||||
|
console.log('[Nakama] Disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 心跳 ============
|
||||||
|
|
||||||
|
_startHeartbeat() {
|
||||||
|
this._stopHeartbeat();
|
||||||
|
const currentHeartbeatId = ++this.heartbeatId;
|
||||||
|
console.log('[Nakama] Starting heartbeat version:', currentHeartbeatId);
|
||||||
|
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
// 如果此心跳 ID 不再是当前活跃 ID,立即停止
|
||||||
|
if (this.heartbeatId !== currentHeartbeatId) {
|
||||||
|
console.log('[Nakama] Zombie heartbeat detected and stopped:', currentHeartbeatId);
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isConnected) {
|
||||||
|
this._send({ ping: {} }, 5000).catch((err) => {
|
||||||
|
console.warn('[Nakama] Heartbeat failed:', err.message);
|
||||||
|
// 如果发送失败且 socketTask 已断开,触发清理
|
||||||
|
if (!this.socketTask || (err.message && err.message.includes('not connected'))) {
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, this.heartbeatInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopHeartbeat() {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 工具方法 ============
|
||||||
|
|
||||||
|
_base64Encode(str) {
|
||||||
|
// 小程序环境没有 btoa,需要手动实现
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
// 将字符串转为 UTF-8 字节数组
|
||||||
|
const bytes = [];
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i);
|
||||||
|
if (code < 0x80) {
|
||||||
|
bytes.push(code);
|
||||||
|
} else if (code < 0x800) {
|
||||||
|
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
||||||
|
} else {
|
||||||
|
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < bytes.length; i += 3) {
|
||||||
|
const b1 = bytes[i];
|
||||||
|
const b2 = bytes[i + 1];
|
||||||
|
const b3 = bytes[i + 2];
|
||||||
|
|
||||||
|
output += chars.charAt(b1 >> 2);
|
||||||
|
output += chars.charAt(((b1 & 3) << 4) | (b2 >> 4) || 0);
|
||||||
|
output += b2 !== undefined ? chars.charAt(((b2 & 15) << 2) | (b3 >> 6) || 0) : '=';
|
||||||
|
output += b3 !== undefined ? chars.charAt(b3 & 63) : '=';
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
_base64ToUint8Array(base64) {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
const lookup = new Uint8Array(256);
|
||||||
|
for (let i = 0; i < chars.length; i++) {
|
||||||
|
lookup[chars.charCodeAt(i)] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bufferLength = base64.length * 0.75;
|
||||||
|
if (base64[base64.length - 1] === '=') bufferLength--;
|
||||||
|
if (base64[base64.length - 2] === '=') bufferLength--;
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(bufferLength);
|
||||||
|
let p = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < base64.length; i += 4) {
|
||||||
|
const e1 = lookup[base64.charCodeAt(i)];
|
||||||
|
const e2 = lookup[base64.charCodeAt(i + 1)];
|
||||||
|
const e3 = lookup[base64.charCodeAt(i + 2)];
|
||||||
|
const e4 = lookup[base64.charCodeAt(i + 3)];
|
||||||
|
|
||||||
|
bytes[p++] = (e1 << 2) | (e2 >> 4);
|
||||||
|
if (base64[i + 2] !== '=') bytes[p++] = ((e2 & 15) << 4) | (e3 >> 2);
|
||||||
|
if (base64[i + 3] !== '=') bytes[p++] = ((e3 & 3) << 6) | e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
_utf8Decode(bytes) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < bytes.length) {
|
||||||
|
let c = bytes[i++];
|
||||||
|
if (c >> 7 == 0) {
|
||||||
|
out += String.fromCharCode(c);
|
||||||
|
} else if (c >> 5 == 0x06) {
|
||||||
|
out += String.fromCharCode(((c & 0x1F) << 6) | (bytes[i++] & 0x3F));
|
||||||
|
} else if (c >> 4 == 0x0E) {
|
||||||
|
out += String.fromCharCode(((c & 0x0F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F));
|
||||||
|
} else {
|
||||||
|
out += String.fromCharCode(((c & 0x07) << 18) | ((bytes[i++] & 0x3F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseUserIdFromToken(token) {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const payload = parts[1];
|
||||||
|
// Base64 URL 解码
|
||||||
|
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = base64 + '=='.slice(0, (4 - base64.length % 4) % 4);
|
||||||
|
|
||||||
|
// 解码 base64
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
const lookup = {};
|
||||||
|
for (let i = 0; i < chars.length; i++) {
|
||||||
|
lookup[chars[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = [];
|
||||||
|
for (let i = 0; i < padded.length; i += 4) {
|
||||||
|
const e1 = lookup[padded[i]] || 0;
|
||||||
|
const e2 = lookup[padded[i + 1]] || 0;
|
||||||
|
const e3 = lookup[padded[i + 2]] || 0;
|
||||||
|
const e4 = lookup[padded[i + 3]] || 0;
|
||||||
|
|
||||||
|
bytes.push((e1 << 2) | (e2 >> 4));
|
||||||
|
if (padded[i + 2] !== '=') bytes.push(((e2 & 15) << 4) | (e3 >> 2));
|
||||||
|
if (padded[i + 3] !== '=') bytes.push(((e3 & 3) << 6) | e4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTF-8 解码
|
||||||
|
let str = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
str += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(str);
|
||||||
|
return parsed.uid || parsed.sub || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Nakama] Failed to parse token:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nakamaManager = new NakamaManager();
|
||||||
134
utils/payment.js
Normal file
134
utils/payment.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 通用支付流程工具函数
|
||||||
|
*
|
||||||
|
* 用于统一 一番赏、无限赏、对对碰 三种玩法的支付流程
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createWechatOrder } from '../api/appUser'
|
||||||
|
import { hasPhoneBound } from './checkPhone'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从API响应中提取订单号
|
||||||
|
* @param {Object} res - API 响应
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function extractOrderNo(res) {
|
||||||
|
if (!res) return null
|
||||||
|
return res.order_no || res.orderNo || res.data?.order_no || res.data?.orderNo || res.result?.order_no || res.result?.orderNo || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行微信支付流程
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.orderNo - 订单号(必须)
|
||||||
|
* @param {string} [options.openid] - 用户 openid(可选,默认从 storage 读取)
|
||||||
|
* @returns {Promise<void>} - 支付完成(成功)时 resolve,取消或失败时 reject
|
||||||
|
*/
|
||||||
|
export async function doWechatPayment({ orderNo, openid }) {
|
||||||
|
if (!orderNo) {
|
||||||
|
throw new Error('订单号不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalOpenid = openid || uni.getStorageSync('openid')
|
||||||
|
if (!finalOpenid) {
|
||||||
|
throw new Error('缺少OpenID,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 获取微信支付参数
|
||||||
|
const payRes = await createWechatOrder({ openid: finalOpenid, order_no: orderNo })
|
||||||
|
if (!payRes || !payRes.package) {
|
||||||
|
throw new Error('获取支付参数失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 调起微信支付
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.requestPayment({
|
||||||
|
provider: 'wxpay',
|
||||||
|
timeStamp: payRes.timeStamp || payRes.timestamp,
|
||||||
|
nonceStr: payRes.nonceStr || payRes.noncestr,
|
||||||
|
package: payRes.package,
|
||||||
|
signType: payRes.signType || 'RSA',
|
||||||
|
paySign: payRes.paySign,
|
||||||
|
success: resolve,
|
||||||
|
fail: (err) => {
|
||||||
|
if (err?.errMsg && String(err.errMsg).includes('cancel')) {
|
||||||
|
const cancelErr = new Error('支付已取消')
|
||||||
|
cancelErr.cancelled = true
|
||||||
|
reject(cancelErr)
|
||||||
|
} else {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整支付流程(创建订单 + 支付)
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {Function} options.createOrder - 创建订单的函数,返回 Promise,结果需包含 order_no
|
||||||
|
* @param {string} [options.openid] - 用户 openid
|
||||||
|
* @param {Function} [options.onOrderCreated] - 订单创建后的回调,参数为 (orderNo, response)
|
||||||
|
* @returns {Promise<{orderNo: string, orderResponse: any}>}
|
||||||
|
*/
|
||||||
|
export async function executePaymentFlow({ createOrder, openid, onOrderCreated }) {
|
||||||
|
// 1. 创建订单
|
||||||
|
const orderResponse = await createOrder()
|
||||||
|
const orderNo = extractOrderNo(orderResponse)
|
||||||
|
|
||||||
|
if (!orderNo) {
|
||||||
|
throw new Error('未获取到订单号')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 回调通知(用于保存游戏数据等)
|
||||||
|
if (typeof onOrderCreated === 'function') {
|
||||||
|
await onOrderCreated(orderNo, orderResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 执行支付
|
||||||
|
await doWechatPayment({ orderNo, openid })
|
||||||
|
|
||||||
|
return { orderNo, orderResponse }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查登录状态
|
||||||
|
* @returns {{ok: boolean, openid?: string, message?: string}}
|
||||||
|
*/
|
||||||
|
export function checkLoginStatus() {
|
||||||
|
const token = uni.getStorageSync('token')
|
||||||
|
const openid = uni.getStorageSync('openid')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, message: '请先登录' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用统一的手机号绑定检查
|
||||||
|
if (!hasPhoneBound()) {
|
||||||
|
return { ok: false, message: '请先绑定手机号' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!openid) {
|
||||||
|
return { ok: false, message: '缺少OpenID,请重新登录' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, openid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示登录提示弹窗
|
||||||
|
*/
|
||||||
|
export function showLoginPrompt() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '请先登录并绑定手机号',
|
||||||
|
confirmText: '去登录',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateTo({ url: '/pages/login/index' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
const BASE_URL = 'https://mini-chat.1024tool.vip'
|
const BASE_URL = 'https://kdy.1024tool.vip'
|
||||||
|
|
||||||
let authModalShown = false
|
let authModalShown = false
|
||||||
|
|
||||||
@ -22,7 +22,6 @@ function handleAuthExpired() {
|
|||||||
export function request({ url, method = 'GET', data = {}, header = {} }) {
|
export function request({ url, method = 'GET', data = {}, header = {} }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const finalHeader = { ...buildDefaultHeaders(), ...header }
|
const finalHeader = { ...buildDefaultHeaders(), ...header }
|
||||||
try { console.log('HTTP request', method, url, 'data', data, 'headers', finalHeader) } catch (e) {}
|
|
||||||
uni.request({
|
uni.request({
|
||||||
url: BASE_URL + url,
|
url: BASE_URL + url,
|
||||||
method,
|
method,
|
||||||
@ -31,7 +30,6 @@ export function request({ url, method = 'GET', data = {}, header = {} }) {
|
|||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
const code = res.statusCode
|
const code = res.statusCode
|
||||||
try { console.log('HTTP response', method, url, 'status', code, 'body', res.data) } catch (e) {}
|
|
||||||
if (code >= 200 && code < 300) {
|
if (code >= 200 && code < 300) {
|
||||||
const body = res.data
|
const body = res.data
|
||||||
resolve(body && body.data !== undefined ? body.data : body)
|
resolve(body && body.data !== undefined ? body.data : body)
|
||||||
@ -42,12 +40,22 @@ export function request({ url, method = 'GET', data = {}, header = {} }) {
|
|||||||
handleAuthExpired()
|
handleAuthExpired()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否是商品缺货错误 (code: 20002)
|
||||||
|
// 仅当是商品详情接口时才弹窗
|
||||||
|
if (res.data && res.data.code === 20002 && url.startsWith('/api/app/products/')) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '商品库存不足',
|
||||||
|
content: '当前商品库存不足,由于市场价格存在波动请联系客服核对价格,补充库存。',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const msg = (res.data && (res.data.message || res.data.msg)) || '请求错误'
|
const msg = (res.data && (res.data.message || res.data.msg)) || '请求错误'
|
||||||
reject({ message: msg, statusCode: code, data: res.data })
|
reject({ message: msg, statusCode: code, data: res.data })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
try { console.error('HTTP fail', method, url, 'err', err) } catch (e) {}
|
|
||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -58,14 +66,21 @@ export function authRequest(options) {
|
|||||||
const token = uni.getStorageSync('token')
|
const token = uni.getStorageSync('token')
|
||||||
const base = buildDefaultHeaders()
|
const base = buildDefaultHeaders()
|
||||||
const header = { ...base, ...(options.header || {}) }
|
const header = { ...base, ...(options.header || {}) }
|
||||||
// 不再添加 Bearer,直接原样透传 token
|
// 设置Authorization头
|
||||||
if (token) {
|
if (token) {
|
||||||
header.Authorization = token
|
header.Authorization = token
|
||||||
header.authorization = token
|
|
||||||
}
|
}
|
||||||
return request({ ...options, header })
|
return request({ ...options, header })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function redeemProductByPoints(user_id, product_id, quantity) {
|
||||||
|
return authRequest({
|
||||||
|
url: `/api/app/users/${user_id}/points/redeem-product`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { product_id, quantity }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getLanguage() {
|
function getLanguage() {
|
||||||
try { return (uni.getSystemInfoSync().language || 'zh-CN') } catch (_) { return 'zh-CN' }
|
try { return (uni.getSystemInfoSync().language || 'zh-CN') } catch (_) { return 'zh-CN' }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,18 @@
|
|||||||
* 用于在抽奖前请求用户授权接收开奖通知
|
* 用于在抽奖前请求用户授权接收开奖通知
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 抽奖结果通知模板 ID
|
// 抽奖结果通知模板 ID (默认兜底)
|
||||||
const LOTTERY_RESULT_TEMPLATE_ID = 'O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI'
|
const DEFAULT_LOTTERY_RESULT_TEMPLATE_ID = 'O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI'
|
||||||
|
|
||||||
|
function getLotteryTemplateId() {
|
||||||
|
try {
|
||||||
|
const templates = uni.getStorageSync('subscribe_templates')
|
||||||
|
if (templates && templates.lottery_result) {
|
||||||
|
return templates.lottery_result
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
return DEFAULT_LOTTERY_RESULT_TEMPLATE_ID
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求用户订阅抽奖结果通知
|
* 请求用户订阅抽奖结果通知
|
||||||
@ -13,15 +23,16 @@ const LOTTERY_RESULT_TEMPLATE_ID = 'O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI'
|
|||||||
export function requestLotterySubscription() {
|
export function requestLotterySubscription() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
|
const tmplId = getLotteryTemplateId()
|
||||||
wx.requestSubscribeMessage({
|
wx.requestSubscribeMessage({
|
||||||
tmplIds: [LOTTERY_RESULT_TEMPLATE_ID],
|
tmplIds: [tmplId],
|
||||||
success(res) {
|
success(res) {
|
||||||
console.log('订阅消息授权结果:', res)
|
console.log('订阅消息授权结果:', res)
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
result: res,
|
result: res,
|
||||||
// 检查用户是否接受了订阅
|
// 检查用户是否接受了订阅
|
||||||
accepted: res[LOTTERY_RESULT_TEMPLATE_ID] === 'accept'
|
accepted: res[tmplId] === 'accept'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fail(err) {
|
fail(err) {
|
||||||
@ -86,5 +97,5 @@ export function requestSubscriptions(templateIds) {
|
|||||||
export default {
|
export default {
|
||||||
requestLotterySubscription,
|
requestLotterySubscription,
|
||||||
requestSubscriptions,
|
requestSubscriptions,
|
||||||
LOTTERY_RESULT_TEMPLATE_ID
|
LOTTERY_RESULT_TEMPLATE_ID: DEFAULT_LOTTERY_RESULT_TEMPLATE_ID
|
||||||
}
|
}
|
||||||
|
|||||||
52
utils/vibrate.js
Normal file
52
utils/vibrate.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 震动工具函数
|
||||||
|
* 统一处理不同平台的震动API兼容性
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短震动
|
||||||
|
* 微信小程序不支持 type 参数,会忽略该参数
|
||||||
|
* @param {Object} options - 配置项
|
||||||
|
* @param {string} options.type - 震动类型 'light' | 'medium' | 'heavy'(仅在部分平台有效)
|
||||||
|
*/
|
||||||
|
export function vibrateShort(options = {}) {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 微信小程序不支持 type 参数,直接调用
|
||||||
|
uni.vibrateShort({
|
||||||
|
fail: (err) => {
|
||||||
|
console.warn('[vibrateShort] 震动失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5 || APP-PLUS
|
||||||
|
// H5和App可能支持 type 参数
|
||||||
|
uni.vibrateShort({
|
||||||
|
...options,
|
||||||
|
fail: (err) => {
|
||||||
|
console.warn('[vibrateShort] 震动失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-ALIPAY || MP-BAIDU || MP-TOUTIAO
|
||||||
|
// 其他小程序平台,尝试传递参数
|
||||||
|
uni.vibrateShort({
|
||||||
|
...options,
|
||||||
|
fail: (err) => {
|
||||||
|
console.warn('[vibrateShort] 震动失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 长震动
|
||||||
|
*/
|
||||||
|
export function vibrateLong() {
|
||||||
|
uni.vibrateLong({
|
||||||
|
fail: (err) => {
|
||||||
|
console.warn('[vibrateLong] 震动失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
5
说明文档.md
5
说明文档.md
@ -31,5 +31,10 @@
|
|||||||
* [x] 2025-12-17: 修复 `pages/login/index.vue` 等多处 `$border-color` 未定义错误,在 `uni.scss` 中增加变量别名。
|
* [x] 2025-12-17: 修复 `pages/login/index.vue` 等多处 `$border-color` 未定义错误,在 `uni.scss` 中增加变量别名。
|
||||||
* [x] 2025-12-17: 修复 `pages/mine/index.vue` 编译错误,在 `api/appUser.js` 中补充 `getUserInfo`, `getUserTasks`, `getInviteRecords` 导出。
|
* [x] 2025-12-17: 修复 `pages/mine/index.vue` 编译错误,在 `api/appUser.js` 中补充 `getUserInfo`, `getUserTasks`, `getInviteRecords` 导出。
|
||||||
* [x] 2025-12-17: 将 dev 分支代码强制推送至 main 分支 (Deployment/Sync)。
|
* [x] 2025-12-17: 将 dev 分支代码强制推送至 main 分支 (Deployment/Sync)。
|
||||||
|
* [x] 2025-12-18: 实现订单详情 API 与取消订单 API (后端接口对接)。
|
||||||
|
* [x] 2025-12-18: 开发订单详情页 UI 及交互逻辑。
|
||||||
|
* [x] 2025-12-22: 修复订单列表不显示问题,移除 source_type=3 过滤,并支持对对碰等玩法订单的正确展示(列表与详情)。
|
||||||
|
* [x] 2025-12-22: 修复订单列表标题显示为 "matching_game:xxx" 内部标识的问题,优化无商品信息时的标题展示。
|
||||||
|
* [x] 2025-12-22: 优化订单详情页,当没有实物商品时(如参与记录)显示活动信息,避免显示空的商品清单。
|
||||||
* [ ] 2025-12-17: 进行中 - 优化 `pages/activity/yifanshang/index.vue` 及相关组件。
|
* [ ] 2025-12-17: 进行中 - 优化 `pages/activity/yifanshang/index.vue` 及相关组件。
|
||||||
* [ ] 2025-12-17: 待开始 - 优化 `pages/login/index.vue` 视觉细节。
|
* [ ] 2025-12-17: 待开始 - 优化 `pages/login/index.vue` 视觉细节。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user