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
|
||||
*.tmp
|
||||
*.swp
|
||||
.claude/settings.local.json
|
||||
.hbuilderx/project.config.json
|
||||
clean-cache.bat
|
||||
|
||||
14
App.vue
14
App.vue
@ -1,4 +1,6 @@
|
||||
<script>
|
||||
import { getPublicConfig } from '@/api/appUser'
|
||||
|
||||
export default {
|
||||
onLaunch: function(options) {
|
||||
console.log('App Launch', options)
|
||||
@ -7,6 +9,18 @@
|
||||
console.log('App Launch captured invite_code:', options.query.invite_code)
|
||||
try { uni.setStorageSync('inviter_code', options.query.invite_code) } catch (e) { console.error('Save invite code failed', e) }
|
||||
}
|
||||
|
||||
// 加载公开配置 (如订阅消息模板ID)
|
||||
getPublicConfig().then(res => {
|
||||
if (res && res.subscribe_templates) {
|
||||
console.log('Loaded public config:', res)
|
||||
try { uni.setStorageSync('subscribe_templates', res.subscribe_templates) } catch (_) {}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('Failed to load public config:', err)
|
||||
})
|
||||
|
||||
// 抖音平台现在也显示首页,不再需要强制跳转
|
||||
},
|
||||
onShow: function() {
|
||||
console.log('App Show')
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
export function getInventory(user_id, page = 1, page_size = 20) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/inventory`, method: 'GET', data: { page, page_size } })
|
||||
// 抖音小程序登录
|
||||
/**
|
||||
* 抖音小程序登录
|
||||
* @param {string} code - 抖音登录 code(从 tt.login 获取)
|
||||
* @param {string} anonymous_code - 匿名登录 code(可选)
|
||||
* @param {string} invite_code - 邀请码(可选)
|
||||
*/
|
||||
export function douyinLogin(code, anonymous_code, invite_code) {
|
||||
const data = {}
|
||||
if (code) data.code = code
|
||||
if (anonymous_code) data.anonymous_code = anonymous_code
|
||||
if (invite_code) data.invite_code = invite_code
|
||||
return request({ url: '/api/app/users/douyin/login', method: 'POST', data })
|
||||
}
|
||||
|
||||
// 保持向后兼容
|
||||
export function toutiaoLogin(code, invite_code) {
|
||||
return douyinLogin(code, null, invite_code)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 短信登录 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
* @param {string} mobile - 手机号
|
||||
*/
|
||||
export function sendSmsCode(mobile) {
|
||||
return request({ url: '/api/app/sms/send-code', method: 'POST', data: { mobile } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 短信验证码登录
|
||||
* @param {string} mobile - 手机号
|
||||
* @param {string} code - 验证码
|
||||
* @param {string} invite_code - 可选邀请码
|
||||
*/
|
||||
export function smsLogin(mobile, code, invite_code) {
|
||||
const data = { mobile, code }
|
||||
if (invite_code) data.invite_code = invite_code
|
||||
return request({ url: '/api/app/sms/login', method: 'POST', data })
|
||||
}
|
||||
|
||||
|
||||
export function getInventory(user_id, page = 1, page_size = 20, params = {}) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/inventory`, method: 'GET', data: { page, page_size, ...params } })
|
||||
}
|
||||
|
||||
export function bindPhone(user_id, code, extraHeader = {}) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/phone/bind`, method: 'POST', data: { code }, header: extraHeader })
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定抖音手机号
|
||||
* @param {number} user_id - 用户ID
|
||||
* @param {string} code - 抖音手机号授权 code
|
||||
*/
|
||||
export function bindDouyinPhone(user_id, code) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/douyin/phone/bind`, method: 'POST', data: { code } })
|
||||
}
|
||||
|
||||
export function getUserStats(user_id) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/stats`, method: 'GET' })
|
||||
}
|
||||
@ -74,12 +128,12 @@ export function getActivityIssueRewards(activity_id, issue_id) {
|
||||
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/rewards`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function drawActivityIssue(activity_id, issue_id) {
|
||||
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw`, method: 'POST' })
|
||||
export function getIssueDrawLogs(activity_id, issue_id) {
|
||||
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw_logs`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function getActivityWinRecords(activity_id, page = 1, page_size = 20) {
|
||||
return authRequest({ url: `/api/app/activities/${activity_id}/wins`, method: 'GET', data: { page, page_size } })
|
||||
export function drawActivityIssue(activity_id, issue_id) {
|
||||
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw`, method: 'POST' })
|
||||
}
|
||||
|
||||
export function getIssueChoices(activity_id, issue_id) {
|
||||
@ -98,8 +152,20 @@ export function requestShipping(user_id, ids) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/inventory/request-shipping`, method: 'POST', data: { inventory_ids: ids } })
|
||||
}
|
||||
|
||||
export function getItemCards(user_id, status) {
|
||||
const data = {}
|
||||
export function cancelShipping(user_id, batch_no) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/inventory/cancel-shipping`, method: 'POST', data: { batch_no } })
|
||||
}
|
||||
|
||||
export function createAddressShare(user_id, inventory_id) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/inventory/address-share/create`, method: 'POST', data: { inventory_id } })
|
||||
}
|
||||
|
||||
export function revokeAddressShare(user_id, inventory_id) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/inventory/address-share/revoke`, method: 'POST', data: { inventory_id } })
|
||||
}
|
||||
|
||||
export function getItemCards(user_id, status, page = 1, page_size = 20) {
|
||||
const data = { page, page_size }
|
||||
if (status !== undefined) data.status = status
|
||||
return authRequest({ url: `/api/app/users/${user_id}/item_cards`, method: 'GET', data })
|
||||
}
|
||||
@ -130,6 +196,24 @@ export function joinLottery(data) {
|
||||
return authRequest({ url: '/api/app/lottery/join', method: 'POST', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 一番赏预下单接口
|
||||
* @param {Object} data - 预下单数据
|
||||
* @param {number} data.activity_id - 活动ID
|
||||
* @param {number} data.issue_id - 期数ID
|
||||
* @param {number[]} data.choices - 选择的位置数组
|
||||
* @param {number} data.coupon_id - 优惠券ID(可选)
|
||||
* @param {number} data.item_card_id - 道具卡ID(可选)
|
||||
* @param {boolean} data.use_game_pass - 是否使用次数卡(可选)
|
||||
*/
|
||||
export function createIchibanPreorder(data) {
|
||||
return authRequest({
|
||||
url: '/api/app/ichiban/preorder',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function createWechatOrder(data) {
|
||||
return authRequest({ url: '/api/app/pay/wechat/jsapi/preorder', method: 'POST', data })
|
||||
}
|
||||
@ -146,10 +230,34 @@ export function redeemProductByPoints(user_id, product_id, quantity) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-product`, method: 'POST', data: { product_id, quantity } })
|
||||
}
|
||||
|
||||
export function redeemItemCardByPoints(user_id, item_card_id, quantity = 1) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-item-card`, method: 'POST', data: { item_card_id, quantity } })
|
||||
}
|
||||
|
||||
export function getStoreItems(kind = 'product', page = 1, page_size = 20, filters = {}) {
|
||||
const data = { kind, page, page_size }
|
||||
if (filters.keyword) data.keyword = filters.keyword
|
||||
if (filters.price_min !== undefined && filters.price_min !== null && filters.price_min !== '') {
|
||||
data.price_min = parseInt(filters.price_min)
|
||||
}
|
||||
if (filters.price_max !== undefined && filters.price_max !== null && filters.price_max !== '') {
|
||||
data.price_max = parseInt(filters.price_max)
|
||||
}
|
||||
return authRequest({ url: '/api/app/store/items', method: 'GET', data })
|
||||
}
|
||||
|
||||
export function getTasks(page = 1, page_size = 20) {
|
||||
return authRequest({ url: '/api/app/task-center/tasks', method: 'GET', data: { page, page_size } })
|
||||
}
|
||||
|
||||
export function getTaskProgress(task_id, user_id) {
|
||||
return authRequest({ url: `/api/app/task-center/tasks/${task_id}/progress/${user_id}`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function claimTaskReward(task_id, user_id, tier_id) {
|
||||
return authRequest({ url: `/api/app/task-center/tasks/${task_id}/claim/${user_id}`, method: 'POST', data: { tier_id } })
|
||||
}
|
||||
|
||||
export function getShipments(user_id, page = 1, page_size = 20) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/shipments`, method: 'GET', data: { page, page_size } })
|
||||
}
|
||||
@ -163,14 +271,142 @@ export function getUserInvites(user_id, page = 1, page_size = 20) {
|
||||
// 兼容性适配接口 (适配 pages/mine/index.vue)
|
||||
// ============================================
|
||||
|
||||
// ============================================
|
||||
// 用户信息修改 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 修改用户信息
|
||||
* @param {number} user_id - 用户ID
|
||||
* @param {object} data - 用户数据 { nickname, avatar(base64) }
|
||||
*/
|
||||
export function modifyUser(user_id, data) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}`, method: 'PUT', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户资料信息(新接口)
|
||||
* @returns {Promise} 用户资料信息 { id, nickname, avatar, mobile, balance, invite_code, inviter_id }
|
||||
*/
|
||||
export function getUserProfile() {
|
||||
return authRequest({ url: '/api/app/users/profile', method: 'GET' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息(兼容旧接口)
|
||||
* @deprecated 建议使用 getUserProfile
|
||||
*/
|
||||
export function getUserInfo() {
|
||||
const user_info = uni.getStorageSync('user_info')
|
||||
if (user_info) return Promise.resolve(user_info)
|
||||
return authRequest({ url: '/api/app/users/info', method: 'GET' })
|
||||
}
|
||||
|
||||
// 获取公开配置
|
||||
export function getPublicConfig() {
|
||||
return request({ url: '/api/app/config/public', method: 'GET' })
|
||||
}
|
||||
|
||||
export const getUserTasks = getTasks
|
||||
export function getInviteRecords(page = 1, page_size = 20) {
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
return getUserInvites(user_id, page, page_size)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 对对碰游戏 (Matching Game) 接口
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 开始游戏
|
||||
* @param {number} issue_id - 对应的活动期次ID
|
||||
*/
|
||||
export function startMatchingGame(issue_id) {
|
||||
return authRequest({ url: '/api/app/matching/start', method: 'POST', data: { issue_id } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行配对 (下一轮)
|
||||
* @param {string} game_id - start接口返回的游戏ID
|
||||
*/
|
||||
export function playMatchingGame(game_id) {
|
||||
return authRequest({ url: '/api/app/matching/play', method: 'POST', data: { game_id } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用的卡牌配置
|
||||
*/
|
||||
export function getMatchingCardTypes() {
|
||||
return authRequest({ url: '/api/app/matching/card_types', method: 'GET' })
|
||||
}
|
||||
|
||||
export function createMatchingPreorder({ issue_id, position, coupon_id = 0, item_card_id = 0, use_game_pass = false }) {
|
||||
return authRequest({
|
||||
url: '/api/app/matching/preorder',
|
||||
method: 'POST',
|
||||
data: { issue_id, position, coupon_id, item_card_id, use_game_pass }
|
||||
})
|
||||
}
|
||||
|
||||
export function checkMatchingGame(game_id, total_pairs) {
|
||||
if (game_id && typeof game_id === 'object') {
|
||||
total_pairs = game_id.total_pairs
|
||||
game_id = game_id.game_id
|
||||
}
|
||||
return authRequest({
|
||||
url: '/api/app/matching/check',
|
||||
method: 'POST',
|
||||
data: { game_id, total_pairs }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后获取游戏数据
|
||||
* @param {string} game_id - 游戏ID
|
||||
*/
|
||||
export function getMatchingGameCards(game_id) {
|
||||
return authRequest({
|
||||
url: '/api/app/matching/cards',
|
||||
method: 'GET',
|
||||
data: { game_id }
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 次数卡 (Game Pass) 接口
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 获取用户可用的次数卡
|
||||
* @param {number} activity_id - 活动ID,不传返回所有
|
||||
*/
|
||||
export function getGamePasses(activity_id) {
|
||||
const data = activity_id ? { activity_id } : {}
|
||||
return authRequest({ url: '/api/app/game-passes/available', method: 'GET', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可购买的次数卡套餐
|
||||
* @param {number} activity_id - 活动ID,不传返回全局套餐
|
||||
*/
|
||||
export function getGamePassPackages(activity_id) {
|
||||
const data = activity_id ? { activity_id } : {}
|
||||
return authRequest({ url: '/api/app/game-passes/packages', method: 'GET', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买次数卡套餐
|
||||
* @param {number} package_id - 套餐ID
|
||||
* @param {number} count - 购买数量
|
||||
*/
|
||||
export function purchaseGamePass(package_id, count = 1) {
|
||||
return authRequest({ url: '/api/app/game-passes/purchase', method: 'POST', data: { package_id, count } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定抖音ID (Buyer ID)
|
||||
* @param {string} douyin_id - 抖音号
|
||||
*/
|
||||
export function bindDouyinID(douyin_id) {
|
||||
return authRequest({ url: '/api/app/users/douyin/bind', method: 'POST', data: { douyin_id } })
|
||||
}
|
||||
|
||||
338
components/BoxReveal.vue
Normal file
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>
|
||||
/* ============================================
|
||||
奇盒潮玩 - 翻牌动画组件
|
||||
柯大鸭潮玩 - 翻牌动画组件
|
||||
采用暖橙色调的开箱效果
|
||||
============================================ */
|
||||
|
||||
@ -110,7 +110,8 @@ defineExpose({ revealResults, reset })
|
||||
}
|
||||
|
||||
.flip-card {
|
||||
perspective: 1000px;
|
||||
perspective: 1200px;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.flip-inner {
|
||||
@ -118,11 +119,18 @@ defineExpose({ revealResults, reset })
|
||||
width: 100%;
|
||||
height: 220rpx;
|
||||
transform-style: preserve-3d;
|
||||
-webkit-transform-style: preserve-3d;
|
||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.flip-card.flipped .flip-inner {
|
||||
transform: rotateY(180deg);
|
||||
animation: flip-reveal 0.9s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.flip-card.flipped {
|
||||
animation: flip-pop 0.35s ease-out;
|
||||
}
|
||||
|
||||
.flip-front, .flip-back {
|
||||
@ -130,6 +138,7 @@ defineExpose({ revealResults, reset })
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -155,6 +164,19 @@ defineExpose({ revealResults, reset })
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes flip-pop {
|
||||
0% { transform: translateZ(0) scale(1); }
|
||||
60% { transform: translateZ(0) scale(1.06); }
|
||||
100% { transform: translateZ(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes flip-reveal {
|
||||
0% { transform: rotateY(0deg) rotateX(0deg) rotateZ(0deg) scale(1); }
|
||||
35% { transform: rotateY(120deg) rotateX(14deg) rotateZ(-6deg) scale(1.08); }
|
||||
70% { transform: rotateY(210deg) rotateX(-10deg) rotateZ(4deg) scale(1.02); }
|
||||
100% { transform: rotateY(180deg) rotateX(0deg) rotateZ(0deg) scale(1); }
|
||||
}
|
||||
|
||||
.flip-back {
|
||||
background: $bg-card;
|
||||
transform: rotateY(180deg);
|
||||
|
||||
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>
|
||||
<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 class="payment-popup-content" @tap.stop>
|
||||
<!-- 顶部提示 -->
|
||||
@ -13,23 +32,53 @@
|
||||
</view>
|
||||
|
||||
<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="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 class="form-item">
|
||||
<text class="label">优惠券</text>
|
||||
<picker
|
||||
class="picker-full"
|
||||
mode="selector"
|
||||
:range="coupons"
|
||||
range-key="name"
|
||||
@change="onCouponChange"
|
||||
:value="couponIndex"
|
||||
:disabled="!coupons || coupons.length === 0"
|
||||
:disabled="(!coupons || coupons.length === 0) || useGamePass"
|
||||
>
|
||||
<view class="picker-display">
|
||||
<text v-if="selectedCoupon" class="selected-text">{{ selectedCoupon.name }} (-¥{{ selectedCoupon.amount }})</text>
|
||||
<view class="picker-display" :class="{ 'picker-disabled': useGamePass }">
|
||||
<text v-if="useGamePass" class="placeholder" style="color: #666;">
|
||||
多次卡不可与优惠券同享
|
||||
</text>
|
||||
<text v-if="selectedCoupon" class="selected-text">
|
||||
{{ selectedCoupon.name }} (-¥{{ effectiveCouponDiscount.toFixed(2) }})
|
||||
<text v-if="selectedCoupon.amount > maxDeductible" style="font-size: 20rpx; color: #FF9800;">(最高抵扣50%)</text>
|
||||
</text>
|
||||
<text v-else-if="!coupons || coupons.length === 0" class="placeholder">暂无优惠券可用</text>
|
||||
<text v-else class="placeholder">请选择优惠券</text>
|
||||
<text class="arrow"></text>
|
||||
@ -40,16 +89,20 @@
|
||||
<view class="form-item" v-if="showCards">
|
||||
<text class="label">道具卡</text>
|
||||
<picker
|
||||
class="picker-full"
|
||||
mode="selector"
|
||||
:range="propCards"
|
||||
range-key="name"
|
||||
:range="displayCards"
|
||||
range-key="displayName"
|
||||
@change="onCardChange"
|
||||
:value="cardIndex"
|
||||
:disabled="!propCards || propCards.length === 0"
|
||||
:disabled="!displayCards || displayCards.length === 0"
|
||||
>
|
||||
<view class="picker-display">
|
||||
<text v-if="selectedCard" class="selected-text">{{ selectedCard.name }}</text>
|
||||
<text v-else-if="!propCards || propCards.length === 0" class="placeholder">暂无道具卡可用</text>
|
||||
<text v-if="selectedCard" class="selected-text">
|
||||
{{ selectedCard.name }}
|
||||
<text v-if="Number(selectedCard.count) > 1" style="color: #999; font-size: 24rpx; margin-left: 6rpx;">(拥有: {{ selectedCard.count }})</text>
|
||||
</text>
|
||||
<text v-else-if="!displayCards || displayCards.length === 0" class="placeholder">暂无道具卡可用</text>
|
||||
<text v-else class="placeholder">请选择道具卡</text>
|
||||
<text class="arrow"></text>
|
||||
</view>
|
||||
@ -59,7 +112,9 @@
|
||||
|
||||
<view class="popup-footer">
|
||||
<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>
|
||||
@ -73,13 +128,101 @@ const props = defineProps({
|
||||
amount: { type: [Number, String], default: 0 },
|
||||
coupons: { type: Array, default: () => [] },
|
||||
propCards: { type: Array, default: () => [] },
|
||||
showCards: { type: Boolean, default: true }
|
||||
showCards: { type: Boolean, default: true },
|
||||
gamePasses: { type: Object, default: () => null } // { total_remaining, passes }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
|
||||
|
||||
// 祝福动画相关
|
||||
const showBlessing = ref(false)
|
||||
const blessings = [
|
||||
{
|
||||
emoji: '🐏',
|
||||
chars: ['三', '羊', '开', '泰'],
|
||||
type: 'sheep'
|
||||
},
|
||||
{
|
||||
emoji: '🐴',
|
||||
chars: ['一', '马', '当', '先'],
|
||||
type: 'horse'
|
||||
},
|
||||
{
|
||||
emoji: '🍊',
|
||||
chars: ['心', '想', '事', '橙'],
|
||||
type: 'orange'
|
||||
},
|
||||
{
|
||||
emoji: '🐵',
|
||||
chars: ['财', '源', '广', '进'],
|
||||
type: 'monkey'
|
||||
},
|
||||
{
|
||||
emoji: '🐮',
|
||||
chars: ['牛', '气', '冲', '天'],
|
||||
type: 'ox'
|
||||
},
|
||||
{
|
||||
emoji: '🐶',
|
||||
chars: ['旺', '旺', '旺', '旺'],
|
||||
type: 'dog'
|
||||
},
|
||||
{
|
||||
emoji: '🐔',
|
||||
chars: ['吉', '祥', '如', '意'],
|
||||
type: 'chicken'
|
||||
}
|
||||
]
|
||||
const currentBlessing = ref(blessings[0])
|
||||
|
||||
// 监听弹窗打开,显示祝福动画
|
||||
watch(() => props.visible, (newVal) => {
|
||||
console.log('[PaymentPopup] visible changed:', newVal)
|
||||
if (newVal) {
|
||||
// 随机选择祝福语
|
||||
const index = Math.floor(Math.random() * blessings.length)
|
||||
currentBlessing.value = blessings[index]
|
||||
|
||||
// 延迟显示祝福动画
|
||||
setTimeout(() => {
|
||||
console.log('[PaymentPopup] 显示祝福动画')
|
||||
showBlessing.value = true
|
||||
|
||||
// 3秒后隐藏祝福动画
|
||||
setTimeout(() => {
|
||||
showBlessing.value = false
|
||||
console.log('[PaymentPopup] 隐藏祝福动画')
|
||||
}, 3000)
|
||||
}, 300) // 延迟300ms,让支付弹窗先滑入
|
||||
} else {
|
||||
showBlessing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const couponIndex = ref(-1)
|
||||
const cardIndex = ref(-1)
|
||||
const useGamePass = ref(false)
|
||||
|
||||
// 次数卡余额
|
||||
const gamePassRemaining = computed(() => {
|
||||
return props.gamePasses?.total_remaining || 0
|
||||
})
|
||||
|
||||
// 监听弹窗打开,若有次数卡则默认选中
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
// 若有次数卡,默认选中
|
||||
useGamePass.value = gamePassRemaining.value > 0
|
||||
}
|
||||
})
|
||||
|
||||
function toggleGamePass() {
|
||||
useGamePass.value = !useGamePass.value
|
||||
// Mutually Exclusive: If Game Pass is ON, clear Coupon.
|
||||
if (useGamePass.value) {
|
||||
couponIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCoupon = computed(() => {
|
||||
if (couponIndex.value >= 0 && props.coupons[couponIndex.value]) {
|
||||
@ -88,20 +231,53 @@ const selectedCoupon = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
const maxDeductible = computed(() => {
|
||||
const amt = Number(props.amount) || 0
|
||||
return amt * 0.5
|
||||
})
|
||||
|
||||
const effectiveCouponDiscount = computed(() => {
|
||||
if (!selectedCoupon.value) return 0
|
||||
const couponAmt = Number(selectedCoupon.value.amount) || 0
|
||||
return Math.min(couponAmt, maxDeductible.value)
|
||||
})
|
||||
|
||||
const displayCards = computed(() => {
|
||||
if (!Array.isArray(props.propCards)) return []
|
||||
return props.propCards.map(c => ({
|
||||
...c,
|
||||
displayName: (c.count && Number(c.count) > 1) ? `${c.name} (x${c.count})` : c.name
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedCard = computed(() => {
|
||||
if (cardIndex.value >= 0 && props.propCards[cardIndex.value]) {
|
||||
return props.propCards[cardIndex.value]
|
||||
if (cardIndex.value >= 0 && displayCards.value[cardIndex.value]) {
|
||||
return displayCards.value[cardIndex.value]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
couponIndex.value = -1
|
||||
cardIndex.value = -1
|
||||
}
|
||||
const finalPayAmount = computed(() => {
|
||||
const amt = Number(props.amount) || 0
|
||||
return Math.max(0, amt - effectiveCouponDiscount.value).toFixed(2)
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => props.visible, () => (Array.isArray(props.coupons) ? props.coupons.length : 0)],
|
||||
([vis, len]) => {
|
||||
if (!vis) return
|
||||
cardIndex.value = -1
|
||||
if (len <= 0) {
|
||||
couponIndex.value = -1
|
||||
return
|
||||
}
|
||||
if (couponIndex.value < 0) {
|
||||
couponIndex.value = 0
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function onCouponChange(e) {
|
||||
couponIndex.value = e.detail.value
|
||||
}
|
||||
@ -112,7 +288,7 @@ function onCardChange(e) {
|
||||
|
||||
function openAgreement() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/agreement/purchase' // 假设协议页面路径,如果没有请替换为实际路径
|
||||
url: '/pages-user/agreement/purchase'
|
||||
})
|
||||
}
|
||||
|
||||
@ -127,15 +303,330 @@ function handleClose() {
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm', {
|
||||
coupon: selectedCoupon.value,
|
||||
card: props.showCards ? selectedCard.value : null
|
||||
coupon: useGamePass.value ? null : selectedCoupon.value,
|
||||
card: (props.showCards && !useGamePass.value) ? selectedCard.value : null,
|
||||
useGamePass: useGamePass.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================
|
||||
奇盒潮玩 - 支付弹窗组件
|
||||
祝福动画样式
|
||||
============================================ */
|
||||
|
||||
.blessing-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.blessing-animation {
|
||||
text-align: center;
|
||||
animation: blessingFadeIn 0.5s ease-out;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(255, 248, 243, 0.98));
|
||||
padding: 40rpx 30rpx;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 12rpx 48rpx rgba(255, 107, 0, 0.3);
|
||||
backdrop-filter: blur(20rpx);
|
||||
border: 2rpx solid rgba(255, 159, 67, 0.3);
|
||||
}
|
||||
|
||||
@keyframes blessingFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.blessing-emoji {
|
||||
font-size: 100rpx;
|
||||
line-height: 1;
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 小羊动画 - 弹跳出现
|
||||
.blessing-animation.sheep .blessing-emoji {
|
||||
animation: emojiBounce 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
// 小马动画 - 从左边跑步进场
|
||||
.blessing-animation.horse .blessing-emoji {
|
||||
animation: emojiRun 1.5s ease-out;
|
||||
}
|
||||
|
||||
// 橙子动画 - 缩放旋转出现(微信小程序优化版)
|
||||
.blessing-animation.orange .blessing-emoji {
|
||||
animation: emojiRotate 1.5s ease-out;
|
||||
}
|
||||
|
||||
// 猴子动画 - 跳跃摇摆出现
|
||||
.blessing-animation.monkey .blessing-emoji {
|
||||
animation: emojiSwing 1.5s ease-out;
|
||||
}
|
||||
|
||||
// 牛动画 - 冲撞弹跳出现
|
||||
.blessing-animation.ox .blessing-emoji {
|
||||
animation: emojiCharge 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
// 狗动画 - 摇尾巴跳动出现
|
||||
.blessing-animation.dog .blessing-emoji {
|
||||
animation: emojiWag 1.5s ease-in-out;
|
||||
}
|
||||
|
||||
// 鸡动画 - 啄米点头出现
|
||||
.blessing-animation.chicken .blessing-emoji {
|
||||
animation: emojiPeck 1.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes emojiBounce {
|
||||
0% {
|
||||
transform: scale(0) rotate(-180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2) rotate(10deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiRun {
|
||||
0% {
|
||||
transform: translateX(-300rpx) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: translateX(30rpx) scale(1.1);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(-15rpx) scale(0.95);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiRotate {
|
||||
0% {
|
||||
transform: scale(0) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.2) rotate(180deg);
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.95) rotate(360deg);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.05) rotate(360deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(360deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiSwing {
|
||||
0% {
|
||||
transform: scale(0) translateY(-50rpx) rotate(-30deg);
|
||||
opacity: 0;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.15) translateY(10rpx) rotate(20deg);
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.9) translateY(-5rpx) rotate(-10deg);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.05) translateY(3rpx) rotate(5deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiCharge {
|
||||
0% {
|
||||
transform: scale(0) translateX(-100rpx) rotate(45deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3) translateX(20rpx) rotate(-20deg);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.85) translateX(-10rpx) rotate(10deg);
|
||||
}
|
||||
85% {
|
||||
transform: scale(1.08) translateX(5rpx) rotate(-5deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateX(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiWag {
|
||||
0% {
|
||||
transform: scale(0) translateY(-30rpx) rotate(-15deg);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.2) translateY(0) rotate(15deg);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.9) translateY(-15rpx) rotate(-15deg);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1) translateY(0) rotate(15deg);
|
||||
}
|
||||
85% {
|
||||
transform: scale(0.95) translateY(-5rpx) rotate(-5deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiPeck {
|
||||
0% {
|
||||
transform: scale(0) translateY(-40rpx) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.15) translateY(10rpx) rotate(10deg);
|
||||
opacity: 1;
|
||||
}
|
||||
40% {
|
||||
transform: scale(0.85) translateY(-5rpx) rotate(-10deg);
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.1) translateY(8rpx) rotate(8deg);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9) translateY(-3rpx) rotate(-8deg);
|
||||
}
|
||||
85% {
|
||||
transform: scale(1.05) translateY(2rpx) rotate(3deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.blessing-subtitle {
|
||||
font-size: 28rpx;
|
||||
color: #FF9500;
|
||||
font-weight: 700;
|
||||
margin-top: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
opacity: 0;
|
||||
animation: subtitleFadeIn 0.5s ease-out 0.3s forwards;
|
||||
}
|
||||
|
||||
@keyframes subtitleFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.blessing-text {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
|
||||
.char {
|
||||
font-size: 48rpx;
|
||||
font-weight: 900;
|
||||
color: #FF6B00;
|
||||
text-shadow: 0 4rpx 12rpx rgba(255, 107, 0, 0.3);
|
||||
opacity: 0;
|
||||
animation: charAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.char.from-left {
|
||||
animation-name: charAppearFromLeft;
|
||||
}
|
||||
|
||||
.char.from-right {
|
||||
animation-name: charAppearFromRight;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes charAppear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30rpx) scale(0.5);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-8rpx) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes charAppearFromLeft {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-80rpx) scale(0.5);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(10rpx) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes charAppearFromRight {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(80rpx) scale(0.5);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-10rpx) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
柯大鸭潮玩 - 支付弹窗组件
|
||||
采用暖橙色调的底部弹窗设计
|
||||
============================================ */
|
||||
|
||||
@ -159,7 +650,9 @@ function handleConfirm() {
|
||||
padding-bottom: calc($spacing-lg + constant(safe-area-inset-bottom));
|
||||
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
|
||||
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
@ -250,6 +743,11 @@ function handleConfirm() {
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.picker-full {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
border: 2rpx solid $border-color-light;
|
||||
border-radius: $radius-md;
|
||||
@ -317,4 +815,120 @@ function handleConfirm() {
|
||||
transform: scale(0.97);
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
/* 次数卡使用按钮特殊样式 */
|
||||
.btn-game-pass {
|
||||
background: linear-gradient(135deg, #10B981, #059669);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
次数卡选项样式
|
||||
============================================ */
|
||||
.game-pass-section {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.game-pass-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
background: linear-gradient(135deg, #ECFDF5, #D1FAE5);
|
||||
border: 2rpx solid #10B981;
|
||||
border-radius: $radius-lg;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #10B981, #059669);
|
||||
border-color: #059669;
|
||||
|
||||
.game-pass-label, .game-pass-count {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.game-pass-radio {
|
||||
background: #FFFFFF;
|
||||
border-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.radio-checked {
|
||||
color: #10B981;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: #F9FAFB;
|
||||
border-color: #E5E7EB;
|
||||
|
||||
.game-pass-radio {
|
||||
border-color: #D1D5DB;
|
||||
background: #F3F4F6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.game-pass-radio {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid #10B981;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: $spacing-sm;
|
||||
}
|
||||
|
||||
.radio-checked {
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
color: #10B981;
|
||||
}
|
||||
|
||||
.game-pass-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.game-pass-label {
|
||||
font-size: $font-md;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.game-pass-count {
|
||||
font-size: $font-sm;
|
||||
color: #10B981;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: $spacing-md;
|
||||
|
||||
&::before, &::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1rpx;
|
||||
background: $border-color-light;
|
||||
}
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
font-size: $font-xs;
|
||||
color: $text-placeholder;
|
||||
padding: 0 $spacing-sm;
|
||||
}
|
||||
|
||||
.radio-disabled {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
background: #D1D5DB;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: #9CA3AF !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
204
components/SplashScreen.vue
Normal file
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>
|
||||
<view class="choice-grid-container">
|
||||
<!-- 调试信息 -->
|
||||
<view style="background: #4caf50; padding: 20rpx; margin: 10rpx;">
|
||||
<text style="color: white;">✅ YifanSelector Component Rendered!</text>
|
||||
<text style="color: white; display: block;">activityId: {{ activityId }}</text>
|
||||
<text style="color: white; display: block;">issueId: {{ issueId }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-state">加载中...</view>
|
||||
<view v-else-if="!choices || choices.length === 0" class="empty-state">暂无可选位置</view>
|
||||
|
||||
@ -25,7 +32,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-bar">
|
||||
<view class="action-bar" v-if="!hideActionBar">
|
||||
<view class="selection-info" v-if="selectedItems.length > 0">
|
||||
已选 <text class="highlight">{{ selectedItems.length }}</text> 个位置
|
||||
</view>
|
||||
@ -34,42 +41,44 @@
|
||||
</view>
|
||||
|
||||
<view class="action-buttons">
|
||||
<button v-if="selectedItems.length === 0" class="btn-common btn-random" @tap="handleRandomOne">随机一发</button>
|
||||
<button v-else class="btn-common btn-buy" @tap="handleBuy">去支付</button>
|
||||
<button v-if="selectedItems.length === 0" class="btn-common btn-random" @tap="handleRandomOne" :disabled="disabled">随机一发</button>
|
||||
<button v-else class="btn-common btn-buy" @tap="handleBuy" :disabled="disabled">去支付</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 支付弹窗 -->
|
||||
<PaymentPopup
|
||||
v-model:visible="paymentVisible"
|
||||
:amount="totalAmount"
|
||||
:coupons="coupons"
|
||||
:showCards="false"
|
||||
@confirm="onPaymentConfirm"
|
||||
/>
|
||||
<!-- 支付弹窗已移至父组件,避免在 scroll-view 内导致定位问题 -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { getIssueChoices, getUserCoupons, joinLottery, createWechatOrder, getLotteryResult } from '@/api/appUser'
|
||||
import PaymentPopup from '@/components/PaymentPopup.vue'
|
||||
import { requestLotterySubscription } from '@/utils/subscribe'
|
||||
|
||||
console.log('[YifanSelector] Script setup running!')
|
||||
|
||||
const props = defineProps({
|
||||
activityId: { type: [String, Number], required: true },
|
||||
issueId: { type: [String, Number], required: true },
|
||||
pricePerDraw: { type: Number, default: 0 } // 单抽价格,用于计算总价
|
||||
pricePerDraw: { type: Number, default: 0 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
disabledText: { type: String, default: '' },
|
||||
hideActionBar: { type: Boolean, default: false } // 支持隐藏内置操作栏
|
||||
})
|
||||
|
||||
const emit = defineEmits(['payment-success'])
|
||||
const emit = defineEmits(['payment-success', 'selection-change', 'payment-visible-change', 'payment-amount-change', 'payment-coupons-change'])
|
||||
|
||||
const choices = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedItems = ref([])
|
||||
const paymentVisible = ref(false)
|
||||
|
||||
// 监听支付弹窗状态变化,通知父组件
|
||||
watch(paymentVisible, (newVal) => {
|
||||
emit('payment-visible-change', newVal)
|
||||
})
|
||||
|
||||
// 模拟优惠券和道具卡数据,实际项目中可能需要从接口获取
|
||||
const coupons = ref([])
|
||||
|
||||
@ -77,23 +86,65 @@ const totalAmount = computed(() => {
|
||||
return (selectedItems.value.length * props.pricePerDraw).toFixed(2)
|
||||
})
|
||||
|
||||
// 监听支付金额变化,通知父组件
|
||||
watch(totalAmount, (newVal) => {
|
||||
emit('payment-amount-change', newVal)
|
||||
})
|
||||
|
||||
// 监听优惠券变化,通知父组件
|
||||
watch(coupons, (newVal) => {
|
||||
emit('payment-coupons-change', newVal)
|
||||
})
|
||||
|
||||
const disabled = computed(() => !!props.disabled)
|
||||
const disabledMessage = computed(() => props.disabledText || '暂不可下单')
|
||||
|
||||
watch(() => props.issueId, (newVal) => {
|
||||
if (newVal) {
|
||||
loadChoices()
|
||||
selectedItems.value = []
|
||||
}
|
||||
}, { immediate: true }) // 添加 immediate 选项,确保初始化时立即执行
|
||||
|
||||
// 监听 activityId 变化,重新加载位置数据
|
||||
watch(() => props.activityId, (newVal) => {
|
||||
if (newVal && props.issueId) {
|
||||
loadChoices()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.disabled, (v) => {
|
||||
if (v && paymentVisible.value) {
|
||||
paymentVisible.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[YifanSelector] Component mounted', {
|
||||
activityId: props.activityId,
|
||||
issueId: props.issueId
|
||||
})
|
||||
if (props.issueId) {
|
||||
loadChoices()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadChoices() {
|
||||
console.log('[YifanSelector] loadChoices called with:', {
|
||||
activityId: props.activityId,
|
||||
issueId: props.issueId
|
||||
})
|
||||
|
||||
if (!props.activityId || !props.issueId) {
|
||||
console.warn('[YifanSelector] Missing activityId or issueId, skipping loadChoices')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
console.log('[YifanSelector] Calling getIssueChoices API...')
|
||||
const res = await getIssueChoices(props.activityId, props.issueId)
|
||||
console.log('[YifanSelector] getIssueChoices response:', res)
|
||||
|
||||
// 处理 { total_slots: 1, available: [1], claimed: [] } 这种格式
|
||||
if (res && typeof res.total_slots === 'number' && Array.isArray(res.available)) {
|
||||
@ -124,6 +175,7 @@ async function loadChoices() {
|
||||
} else {
|
||||
choices.value = []
|
||||
}
|
||||
console.log('[YifanSelector] Choices processed, total:', choices.value.length)
|
||||
} catch (error) {
|
||||
console.error('Failed to load choices:', error)
|
||||
uni.showToast({ title: '加载位置失败', icon: 'none' })
|
||||
@ -137,6 +189,10 @@ function isSelected(item) {
|
||||
}
|
||||
|
||||
function handleSelect(item) {
|
||||
if (disabled.value) {
|
||||
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (item.status === 'sold' || item.is_sold) {
|
||||
return
|
||||
}
|
||||
@ -147,15 +203,29 @@ function handleSelect(item) {
|
||||
} else {
|
||||
selectedItems.value.push(item)
|
||||
}
|
||||
|
||||
// 通知父组件选中变化
|
||||
emit('selection-change', [...selectedItems.value])
|
||||
}
|
||||
|
||||
function handleBuy() {
|
||||
async function handleBuy() {
|
||||
if (disabled.value) {
|
||||
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (selectedItems.value.length === 0) return
|
||||
|
||||
// 主动发送金额和优惠券数据
|
||||
emit('payment-amount-change', totalAmount.value)
|
||||
await fetchCoupons()
|
||||
paymentVisible.value = true
|
||||
fetchCoupons()
|
||||
}
|
||||
|
||||
function handleRandomOne() {
|
||||
async function handleRandomOne() {
|
||||
if (disabled.value) {
|
||||
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||||
return
|
||||
}
|
||||
const available = choices.value.filter(item =>
|
||||
!item.is_sold && item.status !== 'sold' && !isSelected(item)
|
||||
)
|
||||
@ -171,9 +241,10 @@ function handleRandomOne() {
|
||||
// 选中该位置
|
||||
selectedItems.value.push(randomItem)
|
||||
|
||||
// 立即弹出支付
|
||||
// 主动发送金额和优惠券数据
|
||||
emit('payment-amount-change', totalAmount.value)
|
||||
await fetchCoupons()
|
||||
paymentVisible.value = true
|
||||
fetchCoupons()
|
||||
}
|
||||
|
||||
|
||||
@ -195,13 +266,21 @@ async function fetchCoupons() {
|
||||
amount: Number(yuan).toFixed(2)
|
||||
}
|
||||
})
|
||||
// 主动发送优惠券数据给父组件
|
||||
emit('payment-coupons-change', coupons.value)
|
||||
} catch (e) {
|
||||
console.error('fetchCoupons error', e)
|
||||
coupons.value = []
|
||||
emit('payment-coupons-change', [])
|
||||
}
|
||||
}
|
||||
|
||||
async function onPaymentConfirm(paymentData) {
|
||||
if (disabled.value) {
|
||||
paymentVisible.value = false
|
||||
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||||
return
|
||||
}
|
||||
paymentVisible.value = false
|
||||
|
||||
const selectedSlots = selectedItems.value.map(item => item.id || item.position)
|
||||
@ -230,6 +309,7 @@ async function onPaymentConfirm(paymentData) {
|
||||
channel: 'miniapp',
|
||||
count: selectedSlots.length,
|
||||
coupon_id: paymentData.coupon ? Number(paymentData.coupon.id) : 0,
|
||||
use_game_pass: !!paymentData.useGamePass,
|
||||
slot_index: selectedSlots.map(Number)
|
||||
}
|
||||
|
||||
@ -242,6 +322,10 @@ async function onPaymentConfirm(paymentData) {
|
||||
}
|
||||
|
||||
// 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({
|
||||
openid: openid,
|
||||
order_no: orderNo
|
||||
@ -260,6 +344,7 @@ async function onPaymentConfirm(paymentData) {
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
uni.hideLoading()
|
||||
uni.showLoading({ title: '查询结果...' })
|
||||
@ -292,11 +377,22 @@ async function onPaymentConfirm(paymentData) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法供外部调用
|
||||
defineExpose({
|
||||
handleRandomOne,
|
||||
handleBuy,
|
||||
onPaymentConfirm,
|
||||
setPaymentVisible: (visible) => {
|
||||
paymentVisible.value = visible
|
||||
},
|
||||
selectedItems: () => selectedItems.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================
|
||||
奇盒潮玩 - 选号组件 (适配高级卡片布局)
|
||||
柯大鸭潮玩 - 选号组件 (适配高级卡片布局)
|
||||
============================================ */
|
||||
|
||||
/* 容器 - 去除背景,融入父级卡片 */
|
||||
@ -326,8 +422,7 @@ async function onPaymentConfirm(paymentData) {
|
||||
|
||||
/* 网格包装 */
|
||||
.grid-wrapper {
|
||||
padding-bottom: 200rpx; /* 留出底部操作栏空间 */
|
||||
padding: 0 20rpx 200rpx;
|
||||
padding: 0 20rpx 140rpx; /* 减少底部padding */
|
||||
}
|
||||
|
||||
/* 号码网格 - 调整为更合理的列数,适配不同屏幕 */
|
||||
@ -448,39 +543,40 @@ async function onPaymentConfirm(paymentData) {
|
||||
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* ============= 底部操作栏 ============= */
|
||||
/* ============= 底部操作栏 - 对对碰风格胶囊浮动 ============= */
|
||||
.action-bar {
|
||||
position: fixed;
|
||||
left: 32rpx;
|
||||
right: 32rpx;
|
||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
left: 30rpx;
|
||||
right: 30rpx;
|
||||
background: rgba($bg-card, 0.9);
|
||||
backdrop-filter: blur(20rpx);
|
||||
padding: 20rpx 30rpx;
|
||||
box-shadow: $shadow-lg;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(30rpx);
|
||||
padding: 24rpx 40rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
border: 1rpx solid rgba($bg-card, 0.5);
|
||||
animation: slideUp 0.4s ease-out backwards;
|
||||
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.6);
|
||||
animation: slideUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) backwards;
|
||||
}
|
||||
|
||||
/* 选择信息行 */
|
||||
.selection-info {
|
||||
font-size: 26rpx;
|
||||
font-size: 28rpx;
|
||||
color: $text-main;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
align-items: baseline;
|
||||
font-weight: 800;
|
||||
}
|
||||
.highlight {
|
||||
color: $brand-primary;
|
||||
font-weight: 800;
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
font-size: 40rpx;
|
||||
margin: 0 8rpx;
|
||||
font-family: 'DIN Alternate', sans-serif;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
@ -491,54 +587,79 @@ async function onPaymentConfirm(paymentData) {
|
||||
|
||||
/* 通用按钮样式 */
|
||||
.btn-common {
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
padding: 0 48rpx;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
padding: 0 56rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
transform: scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
/* 购买按钮 */
|
||||
/* 购买按钮 - 品牌渐变 + 流光 */
|
||||
.btn-buy {
|
||||
background: $gradient-brand !important;
|
||||
color: #FFFFFF !important;
|
||||
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.3);
|
||||
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* 脉冲动画 */
|
||||
animation: pulse 2s infinite;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -150%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
rgba(255, 255, 255, 0) 30%,
|
||||
rgba(255, 255, 255, 0.4) 50%,
|
||||
rgba(255, 255, 255, 0) 70%
|
||||
);
|
||||
transform: rotate(25deg);
|
||||
animation: btnShine 4s infinite cubic-bezier(0.19, 1, 0.22, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 随机按钮 */
|
||||
/* 随机按钮 - 轻量化设计 */
|
||||
.btn-random {
|
||||
background: $bg-secondary !important;
|
||||
color: $text-main !important;
|
||||
box-shadow: none;
|
||||
border: 1rpx solid transparent;
|
||||
background: #1A1A1A !important;
|
||||
color: $accent-gold !important;
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.15);
|
||||
|
||||
&:active {
|
||||
background: #E5E7EB !important;
|
||||
background: #333 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes btnShine {
|
||||
0% { left: -150%; }
|
||||
100% { left: 150%; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
from { transform: translateY(120rpx); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba($brand-primary, 0.4); }
|
||||
70% { box-shadow: 0 0 0 20rpx rgba($brand-primary, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba($brand-primary, 0); }
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
|
||||
262
components/activity/ActivityHeader.vue
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",
|
||||
"appid" : "",
|
||||
"appid" : "__UNI__07C684D",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
@ -57,7 +57,11 @@
|
||||
"es6" : true,
|
||||
"postcss" : true
|
||||
},
|
||||
"usingComponents" : true
|
||||
"usingComponents" : true,
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
@ -67,7 +71,12 @@
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true,
|
||||
"appid" : "ttf031868c6f33d91001"
|
||||
"appid" : "ttf031868c6f33d91001",
|
||||
"privacy" : {
|
||||
"getPhoneNumber" : {
|
||||
"desc" : "用于登录和账号绑定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
|
||||
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 = ''
|
||||
|
||||
// Navigate to DETAIL, not list
|
||||
if (name.includes('一番赏')) path = '/pages/activity/yifanshang/index'
|
||||
else if (name.includes('无限赏')) path = '/pages/activity/wuxianshang/index'
|
||||
else if (name.includes('对对碰')) path = '/pages/activity/duiduipeng/index'
|
||||
else if (name.includes('爬塔')) path = '/pages/activity/pata/index'
|
||||
if (name.includes('一番赏')) path = '/pages-activity/activity/yifanshang/index'
|
||||
else if (name.includes('无限赏')) path = '/pages-activity/activity/wuxianshang/index'
|
||||
else if (name.includes('对对碰')) path = '/pages-activity/activity/duiduipeng/index'
|
||||
else if (name.includes('爬塔')) path = '/pages-activity/activity/pata/index'
|
||||
|
||||
if (path && id) {
|
||||
uni.navigateTo({ url: `${path}?id=${id}` })
|
||||
@ -122,17 +122,27 @@ import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
onShareAppMessage(() => {
|
||||
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
|
||||
// #ifdef MP-TOUTIAO
|
||||
// 抖音平台分享到商城页面
|
||||
return {
|
||||
title: `${title.value || '精彩活动'} - 奇盒潮玩`,
|
||||
title: `${title.value || '精彩活动'} - 柯大鸭潮玩`,
|
||||
path: `/pages/shop/index?invite_code=${inviteCode}`,
|
||||
imageUrl: '/static/logo.png'
|
||||
}
|
||||
// #endif
|
||||
// #ifndef MP-TOUTIAO
|
||||
return {
|
||||
title: `${title.value || '精彩活动'} - 柯大鸭潮玩`,
|
||||
path: `/pages/index/index?invite_code=${inviteCode}`,
|
||||
imageUrl: '/static/logo.png'
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
onShareTimeline(() => {
|
||||
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
|
||||
return {
|
||||
title: `${title.value || '精彩活动'} - 奇盒潮玩`,
|
||||
title: `${title.value || '精彩活动'} - 柯大鸭潮玩`,
|
||||
query: `invite_code=${inviteCode}`,
|
||||
imageUrl: '/static/logo.png'
|
||||
}
|
||||
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>
|
||||
<input class="input" v-model="mobile" placeholder="请输入手机号" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">省份</text>
|
||||
<input class="input" v-model="province" placeholder="请输入省份" />
|
||||
<view class="form-item region-picker" @click="openRegionPicker">
|
||||
<text class="label">省市区</text>
|
||||
<picker
|
||||
mode="region"
|
||||
:value="regionValue"
|
||||
@change="onRegionChange"
|
||||
@cancel="onRegionCancel"
|
||||
>
|
||||
<view class="picker-value" :class="{ placeholder: !hasRegion }">
|
||||
{{ hasRegion ? `${province} ${city} ${district}` : '请选择省市区' }}
|
||||
<text class="arrow-icon">›</text>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">城市</text>
|
||||
<input class="input" v-model="city" placeholder="请输入城市" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">区县</text>
|
||||
<input class="input" v-model="district" placeholder="请输入区县" />
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">详细地址</text>
|
||||
@ -34,7 +36,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { addAddress, updateAddress, listAddresses, setDefaultAddress } from '../../api/appUser'
|
||||
|
||||
@ -49,6 +51,25 @@ let isDefault = false
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 省市区选择器
|
||||
const regionValue = computed(() => [province.value, city.value, district.value])
|
||||
const hasRegion = computed(() => province.value && city.value && district.value)
|
||||
|
||||
function onRegionChange(e) {
|
||||
const values = e.detail.value
|
||||
province.value = values[0] || ''
|
||||
city.value = values[1] || ''
|
||||
district.value = values[2] || ''
|
||||
}
|
||||
|
||||
function onRegionCancel() {
|
||||
// picker 取消时不做处理
|
||||
}
|
||||
|
||||
function openRegionPicker() {
|
||||
// 点击整行时触发 picker
|
||||
}
|
||||
|
||||
function fill(data) {
|
||||
name.value = data.name || data.realname || ''
|
||||
mobile.value = data.mobile || data.phone || ''
|
||||
@ -150,7 +171,7 @@ onLoad((opts) => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================
|
||||
奇盒潮玩 - 地址编辑页面
|
||||
柯大鸭潮玩 - 地址编辑页面
|
||||
采用暖橙色调的表单设计
|
||||
============================================ */
|
||||
|
||||
@ -193,6 +214,37 @@ onLoad((opts) => {
|
||||
height: 48rpx;
|
||||
}
|
||||
|
||||
/* 省市区选择器 */
|
||||
.region-picker {
|
||||
cursor: pointer;
|
||||
|
||||
picker {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: $font-md;
|
||||
color: $text-main;
|
||||
height: 48rpx;
|
||||
line-height: 48rpx;
|
||||
|
||||
&.placeholder {
|
||||
color: $text-tertiary;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 36rpx;
|
||||
color: $text-tertiary;
|
||||
margin-left: 12rpx;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* 提交按钮 */
|
||||
.submit {
|
||||
width: 100%;
|
||||
472
pages-user/address/index.vue
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>
|
||||
export default {
|
||||
methods: {
|
||||
toUser() { uni.navigateTo({ url: '/pages/agreement/user' }) },
|
||||
toPurchase() { uni.navigateTo({ url: '/pages/agreement/purchase' }) }
|
||||
toUser() { uni.navigateTo({ url: '/pages-user/agreement/user' }) },
|
||||
toPurchase() { uni.navigateTo({ url: '/pages-user/agreement/purchase' }) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
538
pages-user/invite/landing.vue
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>
|
||||
<view class="page-container">
|
||||
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||
<view class="bg-decoration"></view>
|
||||
<!-- 顶部 Tab -->
|
||||
<view class="tabs">
|
||||
<view class="tabs glass-card">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: currentTab === 'pending' }"
|
||||
@ -72,7 +74,7 @@
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="image-overlay" v-if="item.is_winner">
|
||||
<text class="winner-badge">🎉 中奖</text>
|
||||
<text class="winner-badge">🎉 已开启</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -82,6 +84,8 @@
|
||||
<view class="product-meta">
|
||||
<text class="meta-item" v-if="item.activity_name">{{ item.activity_name }}</text>
|
||||
<text class="meta-item" v-if="item.issue_number">第{{ item.issue_number }}期</text>
|
||||
<text class="meta-item coupon-tag" v-if="item.coupon_info">券: {{ item.coupon_info.name }}</text>
|
||||
<text class="meta-item card-tag" v-if="item.item_card_info">卡: {{ item.item_card_info.name }}</text>
|
||||
</view>
|
||||
<text class="order-time">{{ formatTime(item.created_at) }}</text>
|
||||
</view>
|
||||
@ -94,14 +98,14 @@
|
||||
<text class="no-value">{{ item.order_no }}</text>
|
||||
</view>
|
||||
<view class="order-amount">
|
||||
<text class="amount-label">实付</text>
|
||||
<text class="amount-value">{{ formatAmount(item.actual_amount || item.total_amount) }}</text>
|
||||
<text class="amount-label" v-if="shouldShowAmountLabel(item)">实付</text>
|
||||
<text class="amount-value">{{ getAmountText(item) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<view class="order-actions" v-if="currentTab === 'pending'">
|
||||
<button class="action-btn secondary" @tap.stop="cancelOrder(item)">取消订单</button>
|
||||
<button class="action-btn secondary" @tap.stop="doCancelOrder(item)">取消订单</button>
|
||||
<button class="action-btn primary" @tap.stop="payOrder(item)">立即支付</button>
|
||||
</view>
|
||||
</view>
|
||||
@ -124,7 +128,8 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getOrders, cancelOrder as cancelOrderApi } from '../../api/appUser'
|
||||
import { getOrders, cancelOrder as cancelOrderApi, createWechatOrder } from '../../api/appUser'
|
||||
import { vibrateShort } from '@/utils/vibrate.js'
|
||||
|
||||
const currentTab = ref('pending')
|
||||
const orders = ref([])
|
||||
@ -159,7 +164,10 @@ function formatTime(t) {
|
||||
return `${m}-${day} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
function formatAmount(a) {
|
||||
function formatAmount(a, item) {
|
||||
if (item && item.points_amount > 0) {
|
||||
return `${item.points_amount}积分`
|
||||
}
|
||||
if (a === undefined || a === null) return '¥0.00'
|
||||
const n = Number(a)
|
||||
if (Number.isNaN(n)) return '¥0.00'
|
||||
@ -167,20 +175,46 @@ function formatAmount(a) {
|
||||
return `¥${yuan.toFixed(2)}`
|
||||
}
|
||||
|
||||
function shouldShowAmountLabel(item) {
|
||||
const amount = item.actual_amount || item.total_amount
|
||||
return amount > 0
|
||||
}
|
||||
|
||||
function getAmountText(item) {
|
||||
if (item.points_amount > 0) return formatAmount(0, item)
|
||||
const amount = item.actual_amount || item.total_amount
|
||||
if (amount > 0) return formatAmount(amount)
|
||||
|
||||
// 金额为0的情况
|
||||
if (item.source_type === 3 || item.source_type === 2) {
|
||||
return '奖品'
|
||||
}
|
||||
return '免费'
|
||||
}
|
||||
|
||||
function getOrderTitle(item) {
|
||||
// 优先使用 remark 中的商品名称
|
||||
if (item.remark && !item.remark.startsWith('lottery:')) {
|
||||
return item.remark
|
||||
// 1. 优先使用 items 中的商品名称(通常是实物购买或中奖)
|
||||
if (item.items && item.items.length > 0 && item.items[0].title) {
|
||||
return item.items[0].title
|
||||
}
|
||||
// 其次使用 items 中的商品名称
|
||||
if (item.items && item.items.length > 0) {
|
||||
return item.items[0].title || '商品'
|
||||
}
|
||||
// 使用活动名称
|
||||
|
||||
// 2. 其次使用活动名称(玩法类订单)
|
||||
if (item.activity_name) {
|
||||
return item.activity_name
|
||||
}
|
||||
return item.title || item.subject || '订单'
|
||||
|
||||
// 3. 处理 remark,过滤掉内部标识
|
||||
if (item.remark) {
|
||||
// 过滤掉内部标识(如 lottery:xxx, matching_game:xxx 等)
|
||||
if (!item.remark.startsWith('lottery:') &&
|
||||
!item.remark.startsWith('matching_game:') &&
|
||||
!item.remark.includes(':issue:')) {
|
||||
return item.remark
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 保底显示
|
||||
return item.title || item.subject || '盲盒订单'
|
||||
}
|
||||
|
||||
function getProductImage(item) {
|
||||
@ -205,14 +239,28 @@ function getProductImage(item) {
|
||||
|
||||
function getTypeIcon(item) {
|
||||
const sourceType = item.source_type
|
||||
if (sourceType === 2) return '🎰' // 抽奖订单
|
||||
if (sourceType === 2 || sourceType === 3) {
|
||||
// 根据玩法类型显示不同图标
|
||||
const playType = item.play_type
|
||||
if (playType === 'match') return '🎮' // 对对碰
|
||||
if (playType === 'ichiban') return '🎰' // 一番赏
|
||||
if (sourceType === 2) return '🎲' // 默认抽奖
|
||||
}
|
||||
if (sourceType === 1) return '🛒' // 商城订单
|
||||
return '📦'
|
||||
}
|
||||
|
||||
function getTypeName(item) {
|
||||
const sourceType = item.source_type
|
||||
if (sourceType === 2) return '一番赏'
|
||||
if (sourceType === 2 || sourceType === 3) {
|
||||
// 优先使用分类名称,其次活动名称,最后根据玩法类型显示
|
||||
if (item.category_name) return item.category_name
|
||||
if (item.activity_name) return item.activity_name
|
||||
const playType = item.play_type
|
||||
if (playType === 'match') return '对对碰'
|
||||
if (playType === 'ichiban') return '一番赏'
|
||||
if (sourceType === 2) return '抽奖'
|
||||
}
|
||||
if (sourceType === 1) return '商城'
|
||||
return '订单'
|
||||
}
|
||||
@ -242,26 +290,30 @@ function getStatusClass(item) {
|
||||
|
||||
function switchTab(tab) {
|
||||
if (currentTab.value === tab) return
|
||||
vibrateShort()
|
||||
currentTab.value = tab
|
||||
fetchOrders(false)
|
||||
}
|
||||
|
||||
function apiStatus() {
|
||||
return currentTab.value === 'pending' ? 'pending' : 'completed'
|
||||
// 1: 待付款, 2: 已完成
|
||||
return currentTab.value === 'pending' ? 1 : 2
|
||||
}
|
||||
|
||||
// 过滤掉 source_type=3 的发奖订单
|
||||
function filterOrders(items) {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items.filter(item => item.source_type !== 3)
|
||||
// 不再过滤 source_type=3,因为对对碰等玩法的订单也是 source_type=3
|
||||
return items
|
||||
}
|
||||
|
||||
async function fetchOrders(append) {
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
const token = uni.getStorageSync('token')
|
||||
const phoneBound = !!uni.getStorageSync('phone_bound')
|
||||
// 使用统一的手机号绑定检查
|
||||
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
|
||||
|
||||
if (!user_id || !token || !phoneBound) {
|
||||
if (!user_id || !token || !hasPhoneBound) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录并绑定手机号',
|
||||
@ -349,12 +401,18 @@ async function fetchAllOrders() {
|
||||
function goOrderDetail(item) {
|
||||
// 跳转订单详情页
|
||||
uni.navigateTo({
|
||||
url: `/pages/orders/detail?id=${item.id}&order_no=${item.order_no}`
|
||||
url: `/pages-user/orders/detail?id=${item.id}&order_no=${item.order_no}`
|
||||
})
|
||||
}
|
||||
|
||||
function goShopping() {
|
||||
// #ifdef MP-TOUTIAO
|
||||
// 抖音平台跳转到商城
|
||||
uni.switchTab({ url: '/pages/shop/index' })
|
||||
// #endif
|
||||
// #ifndef MP-TOUTIAO
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
// #endif
|
||||
}
|
||||
|
||||
async function doCancelOrder(item) {
|
||||
@ -379,9 +437,65 @@ async function doCancelOrder(item) {
|
||||
})
|
||||
}
|
||||
|
||||
function payOrder(item) {
|
||||
// TODO: 跳转支付
|
||||
uni.showToast({ title: '支付功能开发中', icon: 'none' })
|
||||
async function payOrder(item) {
|
||||
const openid = uni.getStorageSync('openid')
|
||||
if (!openid) {
|
||||
uni.showToast({ title: '缺少OpenID,请重新登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!item || !item.order_no) return
|
||||
|
||||
uni.showLoading({ title: '拉起支付...' })
|
||||
try {
|
||||
const payRes = await createWechatOrder({ openid, order_no: item.order_no })
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: payRes.timeStamp || payRes.timestamp,
|
||||
nonceStr: payRes.nonceStr || payRes.noncestr,
|
||||
package: payRes.package,
|
||||
signType: payRes.signType || 'MD5',
|
||||
paySign: payRes.paySign,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '支付成功', icon: 'success' })
|
||||
navigateToGame(item)
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
if (e?.errMsg && String(e.errMsg).includes('cancel')) {
|
||||
uni.showToast({ title: '支付已取消', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.showToast({ title: e?.message || '支付失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToGame(item) {
|
||||
const playType = item.play_type
|
||||
const activityId = item.activity_id
|
||||
|
||||
if (!activityId) {
|
||||
fetchOrders(false) // 刷新订单列表
|
||||
return
|
||||
}
|
||||
|
||||
let url = ''
|
||||
if (playType === 'match') {
|
||||
url = `/pages-activity/activity/duiduipeng/index?activity_id=${activityId}`
|
||||
} else if (playType === 'ichiban') {
|
||||
url = `/pages-activity/activity/yifanshang/index?activity_id=${activityId}`
|
||||
} else if (playType === 'infinity') {
|
||||
url = `/pages-activity/activity/wuxianshang/index?activity_id=${activityId}`
|
||||
}
|
||||
|
||||
if (url) {
|
||||
uni.navigateTo({ url })
|
||||
} else {
|
||||
fetchOrders(false)
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((opts) => {
|
||||
@ -397,30 +511,34 @@ onReachBottom(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================
|
||||
订单页面 - 高级设计重构
|
||||
柯大鸭潮玩 - 订单页面
|
||||
采用暖橙色调的订单列表设计
|
||||
============================================ */
|
||||
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
position: relative;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部 Tab - 与货柜页面保持一致 */
|
||||
/* 顶部 Tab */
|
||||
.tabs {
|
||||
@extend .glass-card;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 88rpx;
|
||||
background: rgba($bg-card, 0.95);
|
||||
backdrop-filter: blur(20rpx);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
border-radius: 0;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
@ -570,16 +688,19 @@ onReachBottom(() => {
|
||||
|
||||
/* 订单卡片 */
|
||||
.order-card {
|
||||
background: $bg-card;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10rpx);
|
||||
border-radius: $radius-xl;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-card;
|
||||
box-shadow: $shadow-sm;
|
||||
animation: fadeInUp 0.4s ease-out backwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
transition: all 0.2s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -698,6 +819,14 @@ onReachBottom(() => {
|
||||
background: $bg-secondary;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: $radius-sm;
|
||||
.coupon-tag {
|
||||
color: #FF6B6B;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
}
|
||||
.card-tag {
|
||||
color: #6C5CE7;
|
||||
background: rgba(108, 92, 231, 0.1);
|
||||
}
|
||||
}
|
||||
.order-time {
|
||||
font-size: $font-xs;
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="wrap">
|
||||
<!-- 顶部装饰背景 -->
|
||||
<view class="page-bg-decoration"></view>
|
||||
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||
<view class="bg-decoration"></view>
|
||||
|
||||
<view class="header-area">
|
||||
<view class="page-title">积分明细</view>
|
||||
@ -26,14 +26,14 @@
|
||||
class="record-item"
|
||||
:style="{ animationDelay: `${index * 0.05}s` }"
|
||||
>
|
||||
<view class="record-icon" :class="{ 'is-add': (item.change || item.amount || 0) > 0 }">
|
||||
{{ (item.change || item.amount || 0) > 0 ? '↓' : '↑' }}
|
||||
<view class="record-icon" :class="{ 'is-add': (item.points || 0) > 0 }">
|
||||
{{ (item.points || 0) > 0 ? '↓' : '↑' }}
|
||||
</view>
|
||||
<view class="record-content">
|
||||
<view class="record-main">
|
||||
<view class="record-title">{{ item.title || item.reason || '积分变更' }}</view>
|
||||
<view class="record-amount" :class="{ inc: (item.change || item.amount || 0) > 0, dec: (item.change || item.amount || 0) < 0 }">
|
||||
{{ (item.change ?? item.amount ?? 0) > 0 ? '+' : '' }}{{ item.change ?? item.amount ?? 0 }}
|
||||
<view class="record-title">{{ getActionText(item.action) || item.title || item.reason || '积分变更' }}</view>
|
||||
<view class="record-amount" :class="{ inc: (item.points || 0) > 0, dec: (item.points || 0) < 0 }">
|
||||
{{ (item.points ?? 0) > 0 ? '+' : '' }}{{ formatPoints(item.points) }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-footer">
|
||||
@ -65,6 +65,11 @@ const error = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const hasMore = ref(true)
|
||||
function formatPoints(v) {
|
||||
const n = Number(v) || 0
|
||||
if (n === 0) return '0'
|
||||
return n.toString()
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return ''
|
||||
@ -77,11 +82,32 @@ function formatTime(t) {
|
||||
return `${y}-${m}-${day} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
function getActionText(action) {
|
||||
const map = {
|
||||
'signin': '每日签到',
|
||||
'register': '注册赠送',
|
||||
'invite_reward': '邀请奖励',
|
||||
'order_deduct': '下单抵扣',
|
||||
'consume_order': '下单消费',
|
||||
'refund_restore': '退款返还',
|
||||
'refund_points': '积分退回',
|
||||
'refund_amount': '金额退款奖励',
|
||||
'manual_add': '管理手动增加',
|
||||
'manual': '系统调整',
|
||||
'redeem_coupon': '兑换优惠券',
|
||||
'redeem_product': '兑换商品',
|
||||
'redeem_reward': '奖品兑换积分',
|
||||
'redeem_item_card': '兑换道具卡'
|
||||
}
|
||||
return map[action] || ''
|
||||
}
|
||||
|
||||
async function fetchRecords(append = false) {
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
const token = uni.getStorageSync('token')
|
||||
const phoneBound = !!uni.getStorageSync('phone_bound')
|
||||
if (!user_id || !token || !phoneBound) {
|
||||
// 使用统一的手机号绑定检查
|
||||
const hasPhoneBound = uni.getStorageSync('login_method') === 'wechat_phone' || uni.getStorageSync('login_method') === 'sms' || uni.getStorageSync('phone_number')
|
||||
if (!user_id || !token || !hasPhoneBound) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录并绑定手机号',
|
||||
@ -106,7 +132,7 @@ async function fetchRecords(append = false) {
|
||||
error.value = ''
|
||||
try {
|
||||
const list = await getPointsRecords(user_id, page.value, pageSize.value)
|
||||
const items = Array.isArray(list) ? list : (list && list.items) || []
|
||||
const items = Array.isArray(list) ? list : (list && (list.list || list.items)) || []
|
||||
const total = (list && list.total) || 0
|
||||
if (append) {
|
||||
records.value = records.value.concat(items)
|
||||
@ -143,33 +169,27 @@ onReachBottom(() => {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-page;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-bg-decoration {
|
||||
position: absolute;
|
||||
top: -200rpx;
|
||||
right: -200rpx;
|
||||
width: 600rpx;
|
||||
height: 600rpx;
|
||||
background: radial-gradient(circle, rgba($brand-primary, 0.15), transparent 70%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
/* 背景装饰 - 漂浮光球 (与个人中心统一) */
|
||||
|
||||
.header-area {
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
padding-top: calc(env(safe-area-inset-top) + 20rpx);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 900;
|
||||
color: $text-main;
|
||||
margin-bottom: 8rpx;
|
||||
letter-spacing: 1rpx;
|
||||
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: $text-tertiary;
|
||||
196
pages-user/settings/index.vue
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",
|
||||
"style": {
|
||||
"navigationBarTitleText": "uni-app"
|
||||
"navigationBarTitleText": "柯大鸭",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -18,16 +19,10 @@
|
||||
"navigationBarTitleText": "商城"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/shop/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/cabinet/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "货柜"
|
||||
"navigationBarTitleText": "盒柜"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -35,87 +30,206 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/points/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "积分记录"
|
||||
}
|
||||
},
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"path": "pages/orders/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的订单"
|
||||
}
|
||||
},
|
||||
"root": "pages-activity",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/address/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "地址管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/address/edit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "编辑地址"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/help/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "使用帮助"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agreement/user",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户协议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agreement/purchase",
|
||||
"style": {
|
||||
"navigationBarTitleText": "购买协议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/activity/yifanshang/index",
|
||||
"path": "activity/yifanshang/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "一番赏"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/activity/wuxianshang/index",
|
||||
"path": "activity/wuxianshang/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "无限赏"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/activity/duiduipeng/index",
|
||||
"path": "activity/duiduipeng/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "对对碰"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/activity/list/index",
|
||||
"path": "activity/list/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "活动列表"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/activity/pata/index",
|
||||
"path": "activity/pata/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "爬塔"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "pages/register/register",
|
||||
"root": "pages-user",
|
||||
"pages": [
|
||||
{
|
||||
"path": "points/index",
|
||||
"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": {
|
||||
"custom": true,
|
||||
"color": "#7A7E83",
|
||||
"selectedColor": "#007AFF",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
@ -135,7 +249,7 @@
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/cabinet/index",
|
||||
"text": "货柜",
|
||||
"text": "盒柜",
|
||||
"iconPath": "static/tab/box.png",
|
||||
"selectedIconPath": "static/tab/box_active.png"
|
||||
},
|
||||
@ -153,5 +267,11 @@
|
||||
"navigationBarBackgroundColor": "#F8F8F8",
|
||||
"backgroundColor": "#F8F8F8"
|
||||
},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^BlessingAnimation": "@/components/BlessingAnimation.vue"
|
||||
}
|
||||
},
|
||||
"uniIdRouter": {}
|
||||
}
|
||||
@ -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>
|
||||
<view class="wrap">
|
||||
<!-- 顶部装饰背景 - 漂浮光球 -->
|
||||
<view class="bg-decoration"></view>
|
||||
|
||||
<!-- 自定义 tabBar -->
|
||||
<!-- #ifdef MP-TOUTIAO -->
|
||||
<customTabBarToutiao />
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-TOUTIAO -->
|
||||
<customTabBar />
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 顶部 Tab -->
|
||||
<view class="tabs">
|
||||
<view class="tabs glass-card">
|
||||
<view class="tab-item" :class="{ active: currentTab === 0 }" @tap="switchTab(0)">
|
||||
<text class="tab-text">待处理</text>
|
||||
<text class="tab-count" v-if="aggregatedList.length > 0">({{ aggregatedList.length }})</text>
|
||||
@ -35,6 +46,7 @@
|
||||
<text class="item-name">{{ item.name || '未命名道具' }}</text>
|
||||
<text class="item-price" v-if="item.price">单价: ¥{{ item.price }}</text>
|
||||
<view class="item-actions">
|
||||
<text class="invite-btn" v-if="!item.selected" @tap.stop="onInvite(item)">邀请填写</text>
|
||||
<text class="item-count" v-if="!item.selected">x{{ item.count || 1 }}</text>
|
||||
<view class="stepper" v-else @tap.stop>
|
||||
<text class="step-btn minus" @tap.stop="changeCount(item, -1)">-</text>
|
||||
@ -50,7 +62,9 @@
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="bottom-bar" v-if="hasSelected">
|
||||
<view class="selected-info">已选 {{ totalSelectedCount }} 件</view>
|
||||
<view class="selected-info">
|
||||
<text>已选 {{ totalSelectedCount }} 件</text>
|
||||
</view>
|
||||
<view class="btn-group">
|
||||
<button class="action-btn btn-ship" @tap="onShip">发货</button>
|
||||
<button class="action-btn btn-redeem" @tap="onRedeem">兑换</button>
|
||||
@ -73,9 +87,12 @@
|
||||
<text class="batch-no" v-if="item.batch_no">{{ item.batch_no }}</text>
|
||||
<view class="count-badge">{{ item.count }}件商品</view>
|
||||
</view>
|
||||
<view class="shipment-actions">
|
||||
<view class="shipment-status" :class="getStatusClass(item.status)">
|
||||
{{ getStatusText(item.status) }}
|
||||
</view>
|
||||
<text class="shipment-cancel" v-if="Number(item.status) === 1 && item.batch_no" @tap="onCancelShipping(item)">撤销发货</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品缩略图列表 -->
|
||||
@ -126,21 +143,57 @@
|
||||
<view v-if="loading && shippedList.length > 0" class="loading-more">加载更多...</view>
|
||||
<view v-if="!hasMore && shippedList.length > 0" class="no-more">没有更多了</view>
|
||||
</block>
|
||||
<!-- 分享弹窗 -->
|
||||
<view class="share-mask" v-if="showSharePopup" @tap="showSharePopup = false" @touchmove.stop></view>
|
||||
<view class="share-popup glass-card" :class="{ 'show': showSharePopup }">
|
||||
<view class="share-header">
|
||||
<text class="share-title">邀请好友填写地址</text>
|
||||
<text class="share-close" @tap="showSharePopup = false">×</text>
|
||||
</view>
|
||||
<view class="share-body">
|
||||
<view class="share-item-preview">
|
||||
<image class="preview-img" :src="sharingItem.image" mode="aspectFit"></image>
|
||||
<view class="preview-info">
|
||||
<text class="preview-name">{{ sharingItem.name }}</text>
|
||||
<text class="preview-desc">邀请好友填写地址后,该奖品将发货至好友手中,并认领归属于分享账号。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="share-actions">
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button class="action-btn share-card-btn" open-type="share">发送给微信好友</button>
|
||||
<!-- #endif -->
|
||||
<button class="action-btn copy-link-btn" @tap="onCopyShareLink">复制分享链接</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getInventory, getProductDetail, redeemInventory, requestShipping, listAddresses, getShipments } from '@/api/appUser'
|
||||
import { onShow, onReachBottom, onShareAppMessage, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getInventory, getProductDetail, redeemInventory, requestShipping, cancelShipping, listAddresses, getShipments, createAddressShare } from '@/api/appUser'
|
||||
import { vibrateShort } from '@/utils/vibrate.js'
|
||||
import { checkPhoneBound, checkPhoneBoundSync } from '@/utils/checkPhone.js'
|
||||
// #ifdef MP-TOUTIAO
|
||||
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
|
||||
// #endif
|
||||
// #ifndef MP-TOUTIAO
|
||||
import customTabBar from '@/components/app-tab-bar.vue'
|
||||
// #endif
|
||||
|
||||
const currentTab = ref(0)
|
||||
const aggregatedList = ref([])
|
||||
const shippedList = ref([])
|
||||
const showSharePopup = ref(false)
|
||||
const sharingItem = ref({})
|
||||
const currentShareToken = ref('')
|
||||
const currentShortLink = ref('')
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(100)
|
||||
const hasMore = ref(true)
|
||||
const productMetaCache = new Map()
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return aggregatedList.value.reduce((sum, item) => sum + (item.count || 1), 0)
|
||||
@ -158,15 +211,42 @@ const isAllSelected = computed(() => {
|
||||
return aggregatedList.value.length > 0 && aggregatedList.value.every(item => item.selected)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
const token = uni.getStorageSync('token')
|
||||
const phoneBound = !!uni.getStorageSync('phone_bound')
|
||||
console.log('cabinet onShow token:', token, 'isLogin:', !!token, 'phoneBound:', phoneBound)
|
||||
async function fetchProductMeta(productId) {
|
||||
const key = String(productId || '').trim()
|
||||
if (!key) return null
|
||||
if (productMetaCache.has(key)) return productMetaCache.get(key)
|
||||
const res = await getProductDetail(productId)
|
||||
const p = res && (res.data ?? res.result ?? res)
|
||||
const meta = {
|
||||
price: null
|
||||
}
|
||||
const rawPrice = (p && (p.price_sale ?? p.price)) ?? (res && res.price)
|
||||
if (rawPrice !== undefined && rawPrice !== null) {
|
||||
const n = Number(rawPrice)
|
||||
if (!Number.isNaN(n)) meta.price = n / 100
|
||||
}
|
||||
productMetaCache.set(key, meta)
|
||||
return meta
|
||||
}
|
||||
|
||||
if (!token || !phoneBound) {
|
||||
onShow(() => {
|
||||
// 检查手机号绑定状态(快速检查本地缓存)
|
||||
if (!checkPhoneBoundSync()) return
|
||||
|
||||
// Check for external tab switch request
|
||||
try {
|
||||
const targetTab = uni.getStorageSync('cabinet_target_tab')
|
||||
if (targetTab !== '' && targetTab !== null && targetTab !== undefined) {
|
||||
currentTab.value = Number(targetTab)
|
||||
uni.removeStorageSync('cabinet_target_tab')
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const token = uni.getStorageSync('token')
|
||||
if (!token) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录并绑定手机号',
|
||||
content: '请先登录',
|
||||
confirmText: '去登录',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
@ -186,7 +266,21 @@ onShow(() => {
|
||||
if (currentTab.value === 1) {
|
||||
loadShipments(uid)
|
||||
} else {
|
||||
loadAllInventory(uid)
|
||||
loadInventory(uid) // 改为只加载第一页,后续由 onReachBottom 触发
|
||||
}
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
const uid = uni.getStorageSync("user_id")
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
// Reset lists
|
||||
if (currentTab.value === 1) {
|
||||
shippedList.value = []
|
||||
loadShipments(uid).finally(() => uni.stopPullDownRefresh())
|
||||
} else {
|
||||
aggregatedList.value = []
|
||||
loadInventory(uid).finally(() => uni.stopPullDownRefresh())
|
||||
}
|
||||
})
|
||||
|
||||
@ -202,6 +296,7 @@ onReachBottom(() => {
|
||||
})
|
||||
|
||||
function switchTab(index) {
|
||||
if (loading.value) return // 防止切换过快导致并发加载冲突
|
||||
currentTab.value = index
|
||||
// 切换时重新加载数据
|
||||
page.value = 1
|
||||
@ -212,7 +307,7 @@ function switchTab(index) {
|
||||
if (currentTab.value === 1) {
|
||||
loadShipments(uid)
|
||||
} else {
|
||||
loadAllInventory(uid)
|
||||
loadInventory(uid) // 改为按需加载
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,7 +363,8 @@ function getStatusClass(status) {
|
||||
1: 'status-pending', // 待发货
|
||||
2: 'status-shipped', // 已发货
|
||||
3: 'status-delivered', // 已签收
|
||||
4: 'status-cancelled' // 已取消
|
||||
4: 'status-abnormal', // 异常
|
||||
5: 'status-cancelled' // 已取消
|
||||
}
|
||||
return statusMap[status] || 'status-pending'
|
||||
}
|
||||
@ -278,7 +374,8 @@ function getStatusText(status) {
|
||||
1: '待发货',
|
||||
2: '运输中',
|
||||
3: '已签收',
|
||||
4: '已取消'
|
||||
4: '异常',
|
||||
5: '已取消'
|
||||
}
|
||||
return statusMap[status] || '待发货'
|
||||
}
|
||||
@ -349,7 +446,7 @@ async function loadShipments(uid) {
|
||||
|
||||
const next = page.value === 1 ? mapped : [...shippedList.value, ...mapped]
|
||||
shippedList.value = next
|
||||
if (list.length < pageSize.value || (page.value * pageSize.value >= total && total > 0)) { hasMore.value = false } else { page.value += 1 }
|
||||
if (page.value * pageSize.value >= total && total > 0) { hasMore.value = false } else { page.value += 1 }
|
||||
if (list.length === 0) { hasMore.value = false }
|
||||
} catch (e) {
|
||||
console.error('Load shipments error:', e)
|
||||
@ -363,7 +460,11 @@ async function loadInventory(uid) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getInventory(uid, page.value, pageSize.value)
|
||||
const params = {}
|
||||
if (currentTab.value === 0) {
|
||||
params.status = 1
|
||||
}
|
||||
const res = await getInventory(uid, page.value, pageSize.value, params)
|
||||
console.log('Inventory loaded:', res)
|
||||
|
||||
let list = []
|
||||
@ -380,137 +481,37 @@ async function loadInventory(uid) {
|
||||
total = res.length
|
||||
}
|
||||
|
||||
// 过滤 status=1 (正常) 或 status=3 (已使用/已发货/已兑换) 的物品
|
||||
// status=1: 正常在背包
|
||||
// status=3: 已处理(可能是已发货或已兑换积分)
|
||||
const filteredList = list.filter(item => {
|
||||
const s = Number(item.status)
|
||||
return s === 1 || s === 3
|
||||
})
|
||||
// 后端已经按 status 分页并聚合了,这里直接映射返回的 items
|
||||
const nextList = page.value === 1 ? [] : (currentTab.value === 1 ? [...shippedList.value] : [...aggregatedList.value])
|
||||
|
||||
// 调试日志:打印第一条数据以确认字段结构
|
||||
if (filteredList.length > 0) {
|
||||
console.log('Debug Inventory Item:', filteredList[0])
|
||||
}
|
||||
|
||||
// 根据当前 Tab 过滤是否发货
|
||||
const targetItems = filteredList.filter(item => {
|
||||
// Tab 0: 待处理 (has_shipment 为 false 且 status=1)
|
||||
// Tab 1: 已申请发货 (has_shipment 为 true)
|
||||
|
||||
// 注意:API 返回的 has_shipment 可能是布尔值 true/false,也可能是数字 1/0,或者是字符串 "true"/"false"
|
||||
// 这里做一个宽容的判断
|
||||
const isShipped = item.has_shipment === true || item.has_shipment === 1 || String(item.has_shipment) === 'true' || String(item.has_shipment) === '1'
|
||||
|
||||
if (currentTab.value === 1) {
|
||||
// 已申请发货列表:必须是已发货状态
|
||||
// 注意:有些记录 status=3 且 has_shipment=true 表示已发货
|
||||
return isShipped
|
||||
} else {
|
||||
// 待处理列表:未发货且 status=1 (status=3 且未发货的可能是已兑换积分,不应显示在待处理)
|
||||
return !isShipped && Number(item.status) === 1
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Filtered list (status=1, tab=' + currentTab.value + '):', targetItems)
|
||||
|
||||
// 处理新数据
|
||||
const newItems = targetItems.map(item => {
|
||||
let imageUrl = ''
|
||||
try {
|
||||
let rawImg = item.product_images || item.image
|
||||
if (rawImg && typeof rawImg === 'string') {
|
||||
imageUrl = cleanUrl(rawImg)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Image parse error:', e)
|
||||
}
|
||||
|
||||
const isShipped = item.has_shipment === true || item.has_shipment === 1 || String(item.has_shipment) === 'true' || String(item.has_shipment) === '1'
|
||||
|
||||
return {
|
||||
id: item.product_id || item.id, // 优先使用 product_id
|
||||
original_ids: [item.id], // 初始化 id 数组
|
||||
name: (item.product_name || item.name || '').trim(),
|
||||
list.forEach(item => {
|
||||
let imageUrl = cleanUrl(item.product_images || item.image)
|
||||
const mappedItem = {
|
||||
id: item.product_id,
|
||||
original_ids: item.inventory_ids || [],
|
||||
name: (item.product_name || '未知商品').trim(),
|
||||
image: imageUrl,
|
||||
count: 1,
|
||||
price: item.product_price ? item.product_price / 100 : null,
|
||||
count: item.count || 0,
|
||||
selected: false,
|
||||
selectedCount: 1,
|
||||
has_shipment: isShipped,
|
||||
updated_at: item.updated_at // 保留更新时间用于分组
|
||||
selectedCount: item.count || 0,
|
||||
has_shipment: item.has_shipment,
|
||||
updated_at: item.updated_at
|
||||
}
|
||||
nextList.push(mappedItem)
|
||||
})
|
||||
|
||||
console.log('Mapped new items:', newItems.length)
|
||||
|
||||
// 正确的聚合逻辑:
|
||||
// 1. 如果是第一页,直接基于 newItems 生成初始列表(带去重聚合)
|
||||
// 2. 如果是后续页,将 newItems 聚合到现有列表中
|
||||
|
||||
// 深拷贝当前列表
|
||||
let currentList = currentTab.value === 1 ? shippedList : aggregatedList
|
||||
let next = page.value === 1 ? [] : [...currentList.value]
|
||||
console.log('Final list (tab=' + currentTab.value + '):', JSON.parse(JSON.stringify(nextList)))
|
||||
|
||||
if (currentTab.value === 1) {
|
||||
// 已发货列表:按 updated_at 分组展示
|
||||
// 这里我们实际上不按 ID 聚合,而是直接把新数据追加进去,
|
||||
// 但为了 UI 展示,我们可以在前端通过 computed 或在这里预处理进行分组
|
||||
// 为了保持与原有列表结构一致(flat list),我们这里暂时按照 updated_at + product_id 聚合
|
||||
// 或者:既然用户要求按 updated_at 分组,可能希望看到的是“一次发货申请”作为一个卡片?
|
||||
// 这里的实现逻辑是:如果 updated_at 和 product_id 都相同,则聚合数量;否则作为新条目
|
||||
|
||||
newItems.forEach(newItem => {
|
||||
// 查找是否存在 updated_at 和 product_id 都相同的条目
|
||||
// 注意:updated_at 可能是 ISO 字符串,比较前最好截取到秒或直接比较字符串
|
||||
const existingItem = next.find(i =>
|
||||
i.id == newItem.id &&
|
||||
new Date(i.updated_at).getTime() === new Date(newItem.updated_at).getTime()
|
||||
)
|
||||
|
||||
if (existingItem) {
|
||||
existingItem.count += 1
|
||||
if (Array.isArray(existingItem.original_ids)) {
|
||||
existingItem.original_ids.push(...newItem.original_ids)
|
||||
}
|
||||
shippedList.value = nextList
|
||||
} else {
|
||||
next.push(newItem)
|
||||
}
|
||||
})
|
||||
|
||||
} else {
|
||||
// 待处理列表:按 product_id (id) 聚合
|
||||
newItems.forEach(newItem => {
|
||||
if (!newItem.id) {
|
||||
next.push(newItem)
|
||||
return
|
||||
}
|
||||
|
||||
const existingItem = next.find(i => i.id == newItem.id)
|
||||
if (existingItem) {
|
||||
existingItem.count += 1
|
||||
if (Array.isArray(existingItem.original_ids)) {
|
||||
existingItem.original_ids.push(...newItem.original_ids)
|
||||
} else {
|
||||
existingItem.original_ids = [...newItem.original_ids]
|
||||
}
|
||||
} else {
|
||||
next.push(newItem)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Final aggregated list:', JSON.parse(JSON.stringify(next)))
|
||||
|
||||
if (currentTab.value === 1) {
|
||||
shippedList.value = next
|
||||
} else {
|
||||
aggregatedList.value = next
|
||||
aggregatedList.value = nextList
|
||||
}
|
||||
|
||||
// 判断是否还有更多
|
||||
// 注意:这里的 total 是总记录数(未过滤 status=1 之前的),
|
||||
// 我们的分页是基于原始数据的,所以判断依据是原始数据的分页进度
|
||||
if (list.length < pageSize.value || (page.value * pageSize.value >= total && total > 0)) {
|
||||
// 注意:这里的 total 是后端匹配过滤后的总记录数
|
||||
if ((page.value * pageSize.value >= total && total > 0) || list.length === 0) {
|
||||
hasMore.value = false
|
||||
} else {
|
||||
page.value += 1
|
||||
@ -529,50 +530,34 @@ async function loadInventory(uid) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllInventory(uid) {
|
||||
try {
|
||||
while (hasMore.value) {
|
||||
await loadInventory(uid)
|
||||
}
|
||||
fetchProductPrices()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function fetchProductPrices() {
|
||||
const currentList = currentTab.value === 1 ? shippedList : aggregatedList
|
||||
const list = currentList.value
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const item = list[i]
|
||||
if (item.id && !item.price) {
|
||||
try {
|
||||
const res = await getProductDetail(item.id)
|
||||
if (res && (res.price !== undefined || res.data?.price !== undefined)) {
|
||||
// 优先取 res.price,其次 res.data.price (兼容不同返回结构)
|
||||
const raw = res.price !== undefined ? res.price : res.data?.price
|
||||
const num = Number(raw)
|
||||
item.price = isNaN(num) ? null : (num / 100)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch price failed for:', item.id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(item) {
|
||||
vibrateShort()
|
||||
item.selected = !item.selected
|
||||
if (item.selected) {
|
||||
// 选中时默认数量为最大值
|
||||
item.selectedCount = item.count
|
||||
if (!item.price && item.id) {
|
||||
fetchProductMeta(item.id).then(meta => {
|
||||
if (!meta) return
|
||||
if (!item.price && meta.price !== null) item.price = meta.price
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
vibrateShort()
|
||||
const newState = !isAllSelected.value
|
||||
aggregatedList.value.forEach(item => {
|
||||
item.selected = newState
|
||||
if (newState) {
|
||||
item.selectedCount = item.count
|
||||
if (!item.price && item.id) {
|
||||
fetchProductMeta(item.id).then(meta => {
|
||||
if (!meta) return
|
||||
if (!item.price && meta.price !== null) item.price = meta.price
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -586,6 +571,7 @@ function changeCount(item, delta) {
|
||||
}
|
||||
|
||||
async function onRedeem() {
|
||||
vibrateShort()
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
if (!user_id) return
|
||||
|
||||
@ -621,7 +607,7 @@ async function onRedeem() {
|
||||
aggregatedList.value = []
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
loadAllInventory(user_id)
|
||||
loadInventory(user_id)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
|
||||
} finally {
|
||||
@ -633,6 +619,7 @@ async function onRedeem() {
|
||||
}
|
||||
|
||||
async function onShip() {
|
||||
vibrateShort()
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
if (!user_id) return
|
||||
|
||||
@ -653,7 +640,33 @@ async function onShip() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 确认发货
|
||||
// 1. 先检查是否有默认地址
|
||||
try {
|
||||
const addresses = await listAddresses(user_id)
|
||||
const addressList = addresses.list || addresses.data || addresses || []
|
||||
|
||||
if (!addressList || addressList.length === 0) {
|
||||
// 没有默认地址,提示用户跳转到新建地址页面
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '申请发货需要设置默认地址,是否前往新建地址?',
|
||||
confirmText: '前往',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages-user/address/edit' })
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取地址列表失败:', e)
|
||||
uni.showToast({ title: '获取地址失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 有默认地址,确认发货
|
||||
uni.showModal({
|
||||
title: '确认发货',
|
||||
content: `共 ${allIds.length} 件物品,确认申请发货?`,
|
||||
@ -668,7 +681,7 @@ async function onShip() {
|
||||
aggregatedList.value = []
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
loadAllInventory(user_id)
|
||||
loadInventory(user_id)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '申请失败', icon: 'none' })
|
||||
} finally {
|
||||
@ -678,11 +691,100 @@ async function onShip() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onCancelShipping(shipment) {
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
const batchNo = shipment && shipment.batch_no
|
||||
if (!user_id || !batchNo) return
|
||||
uni.showModal({
|
||||
title: '撤销发货',
|
||||
content: `确认不再发货,并撤销发货单 ${batchNo} 吗?`,
|
||||
confirmText: '确认撤销',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
uni.showLoading({ title: '处理中...' })
|
||||
try {
|
||||
await cancelShipping(user_id, batchNo)
|
||||
uni.showToast({ title: '已撤销发货', icon: 'success' })
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
shippedList.value = []
|
||||
await loadShipments(user_id)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '取消失败', icon: 'none' })
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// 微信分享逻辑
|
||||
onShareAppMessage((res) => {
|
||||
showSharePopup.value = false
|
||||
return {
|
||||
title: `送你一个好礼,快来填写地址领走吧!`,
|
||||
path: `/pages-user/address/submit?token=${currentShareToken.value}`,
|
||||
imageUrl: sharingItem.value.image || '/static/logo.png'
|
||||
}
|
||||
})
|
||||
|
||||
async function onInvite(item) {
|
||||
vibrateShort()
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
if (!user_id) {
|
||||
uni.navigateTo({ url: '/pages/login/index' })
|
||||
return
|
||||
}
|
||||
|
||||
// 获取第一个可用的 inventory id
|
||||
const invId = item.original_ids && item.original_ids[0]
|
||||
if (!invId) {
|
||||
uni.showToast({ title: '无效的资产', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '准备分享...' })
|
||||
try {
|
||||
const res = await createAddressShare(user_id, invId)
|
||||
// 兼容不同的数据返回格式
|
||||
currentShareToken.value = res.data?.share_token || res.share_token
|
||||
currentShortLink.value = res.data?.short_link || res.short_link || ''
|
||||
|
||||
// 准备分享预览数据
|
||||
sharingItem.value = {
|
||||
id: invId,
|
||||
name: item.name,
|
||||
image: item.image,
|
||||
count: item.count
|
||||
}
|
||||
|
||||
// 显示分享弹窗
|
||||
showSharePopup.value = true
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '生成失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
function onCopyShareLink() {
|
||||
let url = currentShortLink.value
|
||||
if (!url) {
|
||||
url = `${window?.location?.origin || ''}/pages-user/address/submit?token=${currentShareToken.value}`
|
||||
}
|
||||
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制链接', icon: 'success' })
|
||||
showSharePopup.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================
|
||||
奇盒潮玩 - 货柜页面
|
||||
柯大鸭潮玩 - 盒柜页面
|
||||
采用现代卡片式布局,统一设计语言
|
||||
============================================ */
|
||||
|
||||
@ -693,25 +795,28 @@ async function onShip() {
|
||||
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部 Tab */
|
||||
.tabs {
|
||||
@extend .glass-card;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 88rpx;
|
||||
background: rgba($bg-card, 0.9);
|
||||
backdrop-filter: blur(20rpx);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
box-shadow: $shadow-sm;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
@ -887,9 +992,22 @@ async function onShip() {
|
||||
.item-actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.invite-btn {
|
||||
font-size: 22rpx;
|
||||
color: $brand-primary;
|
||||
background: rgba($brand-primary, 0.1);
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
font-weight: 500;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
background: rgba($brand-primary, 0.2);
|
||||
}
|
||||
}
|
||||
.item-count {
|
||||
font-size: 28rpx;
|
||||
color: $text-main;
|
||||
@ -940,7 +1058,7 @@ async function onShip() {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
|
||||
z-index: 99;
|
||||
z-index: 1000; /* 提高z-index,确保在tabbar(999)上方 */
|
||||
height: auto; /* reset old height */
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
@ -948,6 +1066,9 @@ async function onShip() {
|
||||
font-size: 28rpx;
|
||||
color: $text-main;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
.btn-group {
|
||||
display: flex;
|
||||
@ -1005,6 +1126,12 @@ async function onShip() {
|
||||
padding-bottom: 20rpx;
|
||||
border-bottom: 1rpx solid rgba(0,0,0,0.05);
|
||||
}
|
||||
.shipment-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.shipment-batch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -1038,6 +1165,13 @@ async function onShip() {
|
||||
&.status-delivered { background: #F6FFED; color: #52C41A; }
|
||||
&.status-cancelled { background: #F5F5F5; color: #999; }
|
||||
}
|
||||
.shipment-cancel {
|
||||
font-size: 22rpx;
|
||||
color: $text-sub;
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.product-thumbnails {
|
||||
margin-bottom: 24rpx;
|
||||
@ -1148,12 +1282,124 @@ async function onShip() {
|
||||
from { opacity: 0; transform: translateY(20rpx); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.bottom-spacer {
|
||||
height: 120rpx;
|
||||
}
|
||||
|
||||
.share-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 998;
|
||||
backdrop-filter: blur(4rpx);
|
||||
}
|
||||
|
||||
.share-popup {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
border-radius: 40rpx 40rpx 0 0;
|
||||
z-index: 999;
|
||||
padding: 40rpx;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
|
||||
&.show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.share-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 40rpx;
|
||||
|
||||
.share-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
.share-close {
|
||||
font-size: 40rpx;
|
||||
color: $text-sub;
|
||||
padding: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.share-item-preview {
|
||||
display: flex;
|
||||
background: #f8f9fa;
|
||||
padding: 30rpx;
|
||||
border-radius: 24rpx;
|
||||
margin-bottom: 50rpx;
|
||||
|
||||
.preview-img {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-right: 20rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.preview-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $text-main;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.preview-desc {
|
||||
font-size: 20rpx;
|
||||
color: $text-sub;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.share-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
|
||||
.action-btn {
|
||||
height: 90rpx;
|
||||
border-radius: 45rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
|
||||
&::after { border: none; }
|
||||
|
||||
&.share-card-btn {
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.copy-link-btn {
|
||||
background: #f0f0f0;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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-orange: #FF9500; // 活力橙
|
||||
$accent-red: #FF3B30; // 促销红
|
||||
$accent-blue: #007AFF; // 科技蓝
|
||||
$accent-purple: #AF52DE; // 梦幻紫
|
||||
@ -183,3 +184,94 @@ $uni-font-size-paragraph: 15px;
|
||||
-webkit-line-clamp: $lines;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
💎 核心公共 UI 类 (Premium UI 6.0)
|
||||
============================================ */
|
||||
|
||||
/* 1. 统一背景装饰 - 漂浮光球 */
|
||||
.bg-decoration {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -100rpx;
|
||||
right: -100rpx;
|
||||
width: 600rpx;
|
||||
height: 600rpx;
|
||||
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
|
||||
filter: blur(60rpx);
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
animation: float 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 200rpx;
|
||||
left: -200rpx;
|
||||
width: 500rpx;
|
||||
height: 500rpx;
|
||||
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
|
||||
filter: blur(50rpx);
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
animation: float 15s ease-in-out infinite reverse;
|
||||
}
|
||||
}
|
||||
|
||||
/* 2. 毛玻璃卡片基类 */
|
||||
.glass-card {
|
||||
background: $bg-glass;
|
||||
backdrop-filter: blur(20rpx);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: $shadow-card;
|
||||
border-radius: $radius-lg;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 3. 通用功能按钮 */
|
||||
.btn-primary {
|
||||
background: $gradient-brand;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
border-radius: $radius-round;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: $shadow-warm;
|
||||
transition: all 0.2s $ease-out;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: $text-main;
|
||||
font-weight: 600;
|
||||
border-radius: $radius-round;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: $shadow-sm;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s $ease-out;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
201
utils/activity.js
Normal file
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
|
||||
|
||||
@ -22,7 +22,6 @@ function handleAuthExpired() {
|
||||
export function request({ url, method = 'GET', data = {}, header = {} }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const finalHeader = { ...buildDefaultHeaders(), ...header }
|
||||
try { console.log('HTTP request', method, url, 'data', data, 'headers', finalHeader) } catch (e) {}
|
||||
uni.request({
|
||||
url: BASE_URL + url,
|
||||
method,
|
||||
@ -31,7 +30,6 @@ export function request({ url, method = 'GET', data = {}, header = {} }) {
|
||||
timeout: 15000,
|
||||
success: (res) => {
|
||||
const code = res.statusCode
|
||||
try { console.log('HTTP response', method, url, 'status', code, 'body', res.data) } catch (e) {}
|
||||
if (code >= 200 && code < 300) {
|
||||
const body = res.data
|
||||
resolve(body && body.data !== undefined ? body.data : body)
|
||||
@ -42,12 +40,22 @@ export function request({ url, method = 'GET', data = {}, header = {} }) {
|
||||
handleAuthExpired()
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是商品缺货错误 (code: 20002)
|
||||
// 仅当是商品详情接口时才弹窗
|
||||
if (res.data && res.data.code === 20002 && url.startsWith('/api/app/products/')) {
|
||||
uni.showModal({
|
||||
title: '商品库存不足',
|
||||
content: '当前商品库存不足,由于市场价格存在波动请联系客服核对价格,补充库存。',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
const msg = (res.data && (res.data.message || res.data.msg)) || '请求错误'
|
||||
reject({ message: msg, statusCode: code, data: res.data })
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
try { console.error('HTTP fail', method, url, 'err', err) } catch (e) {}
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
@ -58,14 +66,21 @@ export function authRequest(options) {
|
||||
const token = uni.getStorageSync('token')
|
||||
const base = buildDefaultHeaders()
|
||||
const header = { ...base, ...(options.header || {}) }
|
||||
// 不再添加 Bearer,直接原样透传 token
|
||||
// 设置Authorization头
|
||||
if (token) {
|
||||
header.Authorization = token
|
||||
header.authorization = token
|
||||
}
|
||||
return request({ ...options, header })
|
||||
}
|
||||
|
||||
export function redeemProductByPoints(user_id, product_id, quantity) {
|
||||
return authRequest({
|
||||
url: `/api/app/users/${user_id}/points/redeem-product`,
|
||||
method: 'POST',
|
||||
data: { product_id, quantity }
|
||||
})
|
||||
}
|
||||
|
||||
function getLanguage() {
|
||||
try { return (uni.getSystemInfoSync().language || 'zh-CN') } catch (_) { return 'zh-CN' }
|
||||
}
|
||||
|
||||
@ -3,8 +3,18 @@
|
||||
* 用于在抽奖前请求用户授权接收开奖通知
|
||||
*/
|
||||
|
||||
// 抽奖结果通知模板 ID
|
||||
const LOTTERY_RESULT_TEMPLATE_ID = 'O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI'
|
||||
// 抽奖结果通知模板 ID (默认兜底)
|
||||
const DEFAULT_LOTTERY_RESULT_TEMPLATE_ID = 'O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI'
|
||||
|
||||
function getLotteryTemplateId() {
|
||||
try {
|
||||
const templates = uni.getStorageSync('subscribe_templates')
|
||||
if (templates && templates.lottery_result) {
|
||||
return templates.lottery_result
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
return DEFAULT_LOTTERY_RESULT_TEMPLATE_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求用户订阅抽奖结果通知
|
||||
@ -13,15 +23,16 @@ const LOTTERY_RESULT_TEMPLATE_ID = 'O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI'
|
||||
export function requestLotterySubscription() {
|
||||
return new Promise((resolve) => {
|
||||
// #ifdef MP-WEIXIN
|
||||
const tmplId = getLotteryTemplateId()
|
||||
wx.requestSubscribeMessage({
|
||||
tmplIds: [LOTTERY_RESULT_TEMPLATE_ID],
|
||||
tmplIds: [tmplId],
|
||||
success(res) {
|
||||
console.log('订阅消息授权结果:', res)
|
||||
resolve({
|
||||
success: true,
|
||||
result: res,
|
||||
// 检查用户是否接受了订阅
|
||||
accepted: res[LOTTERY_RESULT_TEMPLATE_ID] === 'accept'
|
||||
accepted: res[tmplId] === 'accept'
|
||||
})
|
||||
},
|
||||
fail(err) {
|
||||
@ -86,5 +97,5 @@ export function requestSubscriptions(templateIds) {
|
||||
export default {
|
||||
requestLotterySubscription,
|
||||
requestSubscriptions,
|
||||
LOTTERY_RESULT_TEMPLATE_ID
|
||||
LOTTERY_RESULT_TEMPLATE_ID: DEFAULT_LOTTERY_RESULT_TEMPLATE_ID
|
||||
}
|
||||
|
||||
52
utils/vibrate.js
Normal file
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/mine/index.vue` 编译错误,在 `api/appUser.js` 中补充 `getUserInfo`, `getUserTasks`, `getInviteRecords` 导出。
|
||||
* [x] 2025-12-17: 将 dev 分支代码强制推送至 main 分支 (Deployment/Sync)。
|
||||
* [x] 2025-12-18: 实现订单详情 API 与取消订单 API (后端接口对接)。
|
||||
* [x] 2025-12-18: 开发订单详情页 UI 及交互逻辑。
|
||||
* [x] 2025-12-22: 修复订单列表不显示问题,移除 source_type=3 过滤,并支持对对碰等玩法订单的正确展示(列表与详情)。
|
||||
* [x] 2025-12-22: 修复订单列表标题显示为 "matching_game:xxx" 内部标识的问题,优化无商品信息时的标题展示。
|
||||
* [x] 2025-12-22: 优化订单详情页,当没有实物商品时(如参与记录)显示活动信息,避免显示空的商品清单。
|
||||
* [ ] 2025-12-17: 进行中 - 优化 `pages/activity/yifanshang/index.vue` 及相关组件。
|
||||
* [ ] 2025-12-17: 待开始 - 优化 `pages/login/index.vue` 视觉细节。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user