602 lines
15 KiB
Vue
602 lines
15 KiB
Vue
<template>
|
||
<view class="choice-grid-container">
|
||
<view v-if="loading" class="loading-state">加载中...</view>
|
||
<view v-else-if="!choices || choices.length === 0" class="empty-state">暂无可选位置</view>
|
||
|
||
<view v-else class="grid-wrapper">
|
||
<view class="choices-grid">
|
||
<view
|
||
v-for="(item, index) in choices"
|
||
:key="item.id || index"
|
||
class="choice-item"
|
||
:class="{
|
||
'is-sold': item.status === 'sold' || item.is_sold,
|
||
'is-selected': isSelected(item),
|
||
'is-available': !item.status || item.status === 'available'
|
||
}"
|
||
@tap="handleSelect(item)"
|
||
>
|
||
<text class="choice-number">{{ item.number || item.position || index + 1 }}</text>
|
||
<view class="choice-status">
|
||
<text v-if="item.status === 'sold' || item.is_sold">已售</text>
|
||
<text v-else-if="isSelected(item)">已选</text>
|
||
<text v-else>可选</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="action-bar">
|
||
<view class="selection-info" v-if="selectedItems.length > 0">
|
||
已选 <text class="highlight">{{ selectedItems.length }}</text> 个位置
|
||
</view>
|
||
<view class="selection-info" v-else>
|
||
请选择位置
|
||
</view>
|
||
|
||
<view class="action-buttons">
|
||
<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"
|
||
/>
|
||
</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'
|
||
|
||
const props = defineProps({
|
||
activityId: { type: [String, Number], required: true },
|
||
issueId: { type: [String, Number], required: true },
|
||
pricePerDraw: { type: Number, default: 0 },
|
||
disabled: { type: Boolean, default: false },
|
||
disabledText: { type: String, default: '' }
|
||
})
|
||
|
||
const emit = defineEmits(['payment-success'])
|
||
|
||
const choices = ref([])
|
||
const loading = ref(false)
|
||
const selectedItems = ref([])
|
||
const paymentVisible = ref(false)
|
||
|
||
// 模拟优惠券和道具卡数据,实际项目中可能需要从接口获取
|
||
const coupons = ref([])
|
||
|
||
const totalAmount = computed(() => {
|
||
return (selectedItems.value.length * props.pricePerDraw).toFixed(2)
|
||
})
|
||
|
||
const disabled = computed(() => !!props.disabled)
|
||
const disabledMessage = computed(() => props.disabledText || '暂不可下单')
|
||
|
||
watch(() => props.issueId, (newVal) => {
|
||
if (newVal) {
|
||
loadChoices()
|
||
selectedItems.value = []
|
||
}
|
||
})
|
||
|
||
watch(() => props.disabled, (v) => {
|
||
if (v && paymentVisible.value) {
|
||
paymentVisible.value = false
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
if (props.issueId) {
|
||
loadChoices()
|
||
}
|
||
})
|
||
|
||
async function loadChoices() {
|
||
loading.value = true
|
||
try {
|
||
const res = await getIssueChoices(props.activityId, props.issueId)
|
||
|
||
// 处理 { total_slots: 1, available: [1], claimed: [] } 这种格式
|
||
if (res && typeof res.total_slots === 'number' && Array.isArray(res.available)) {
|
||
const total = res.total_slots
|
||
const list = []
|
||
// 转换为 Set 提高查找效率,确保类型一致
|
||
const availableSet = new Set(res.available.map(v => Number(v)))
|
||
|
||
for (let i = 1; i <= total; i++) {
|
||
const isAvailable = availableSet.has(i)
|
||
list.push({
|
||
id: i,
|
||
number: i,
|
||
position: i,
|
||
status: isAvailable ? 'available' : 'sold',
|
||
is_sold: !isAvailable
|
||
})
|
||
}
|
||
choices.value = list
|
||
}
|
||
// 兼容旧的/其他的返回结构
|
||
else if (Array.isArray(res)) {
|
||
choices.value = res
|
||
} else if (res && Array.isArray(res.data)) {
|
||
choices.value = res.data
|
||
} else if (res && Array.isArray(res.choices)) {
|
||
choices.value = res.choices
|
||
} else {
|
||
choices.value = []
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load choices:', error)
|
||
uni.showToast({ title: '加载位置失败', icon: 'none' })
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function isSelected(item) {
|
||
return selectedItems.value.some(i => i.id === item.id || (i.position && i.position === item.position))
|
||
}
|
||
|
||
function handleSelect(item) {
|
||
if (disabled.value) {
|
||
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||
return
|
||
}
|
||
if (item.status === 'sold' || item.is_sold) {
|
||
return
|
||
}
|
||
|
||
const index = selectedItems.value.findIndex(i => i.id === item.id || (i.position && i.position === item.position))
|
||
if (index > -1) {
|
||
selectedItems.value.splice(index, 1)
|
||
} else {
|
||
selectedItems.value.push(item)
|
||
}
|
||
}
|
||
|
||
function handleBuy() {
|
||
if (disabled.value) {
|
||
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||
return
|
||
}
|
||
if (selectedItems.value.length === 0) return
|
||
paymentVisible.value = true
|
||
fetchCoupons()
|
||
}
|
||
|
||
function handleRandomOne() {
|
||
if (disabled.value) {
|
||
uni.showToast({ title: disabledMessage.value, icon: 'none' })
|
||
return
|
||
}
|
||
const available = choices.value.filter(item =>
|
||
!item.is_sold && item.status !== 'sold' && !isSelected(item)
|
||
)
|
||
|
||
if (available.length === 0) {
|
||
uni.showToast({ title: '没有可选位置了', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const randomIndex = Math.floor(Math.random() * available.length)
|
||
const randomItem = available[randomIndex]
|
||
|
||
// 选中该位置
|
||
selectedItems.value.push(randomItem)
|
||
|
||
// 立即弹出支付
|
||
paymentVisible.value = true
|
||
fetchCoupons()
|
||
}
|
||
|
||
|
||
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 cents = (i.remaining !== undefined && i.remaining !== null) ? Number(i.remaining) : Number(i.amount ?? i.value ?? 0)
|
||
const yuan = isNaN(cents) ? 0 : (cents / 100)
|
||
return {
|
||
id: i.id ?? i.coupon_id ?? String(idx),
|
||
name: i.name ?? i.title ?? '优惠券',
|
||
amount: Number(yuan).toFixed(2)
|
||
}
|
||
})
|
||
} catch (e) {
|
||
console.error('fetchCoupons error', e)
|
||
coupons.value = []
|
||
}
|
||
}
|
||
|
||
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)
|
||
|
||
if (selectedSlots.length === 0) {
|
||
uni.showToast({ title: '未选择位置', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const openid = uni.getStorageSync('openid')
|
||
if (!openid) {
|
||
uni.showToast({ title: '未获取到OpenID,请重新登录', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
// 请求用户订阅开奖通知
|
||
await requestLotterySubscription()
|
||
|
||
uni.showLoading({ title: '处理中...' })
|
||
|
||
try {
|
||
// 1. 先调用抽奖接口 (joinLottery),服务器会返回订单号等信息
|
||
const payload = {
|
||
activity_id: Number(props.activityId),
|
||
issue_id: Number(props.issueId),
|
||
channel: 'miniapp',
|
||
count: selectedSlots.length,
|
||
coupon_id: paymentData.coupon ? Number(paymentData.coupon.id) : 0,
|
||
slot_index: selectedSlots.map(Number)
|
||
}
|
||
|
||
const joinRes = await joinLottery(payload)
|
||
// 假设 joinRes 包含 order_no,如果结构不同请调整
|
||
const orderNo = joinRes.order_no || joinRes.data?.order_no || joinRes.result?.order_no
|
||
|
||
if (!orderNo) {
|
||
throw new Error('未获取到订单号')
|
||
}
|
||
|
||
// 2. 使用返回的订单号去发起支付
|
||
const payRes = await createWechatOrder({
|
||
openid: openid,
|
||
order_no: orderNo
|
||
})
|
||
|
||
// 调起微信支付
|
||
await new Promise((resolve, reject) => {
|
||
uni.requestPayment({
|
||
provider: 'wxpay',
|
||
timeStamp: payRes.timeStamp || payRes.timestamp,
|
||
nonceStr: payRes.nonceStr || payRes.noncestr,
|
||
package: payRes.package,
|
||
signType: payRes.signType || 'MD5',
|
||
paySign: payRes.paySign,
|
||
success: resolve,
|
||
fail: reject
|
||
})
|
||
})
|
||
|
||
uni.hideLoading()
|
||
uni.showLoading({ title: '查询结果...' })
|
||
|
||
// 3. 支付成功,查询抽奖结果
|
||
const resultRes = await getLotteryResult(orderNo)
|
||
console.log('Lottery Result:', resultRes) // 打印结果供查看
|
||
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '支付成功', icon: 'success' })
|
||
|
||
// 触发支付成功事件
|
||
emit('payment-success', {
|
||
result: resultRes,
|
||
items: selectedItems.value
|
||
})
|
||
|
||
// 清空选择并刷新
|
||
selectedItems.value = []
|
||
loadChoices()
|
||
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.error('Flow failed:', e)
|
||
|
||
if (e.errMsg && e.errMsg.indexOf('cancel') !== -1) {
|
||
uni.showToast({ title: '支付已取消', icon: 'none' })
|
||
} else {
|
||
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
/* ============================================
|
||
柯大鸭潮玩 - 选号组件 (适配高级卡片布局)
|
||
============================================ */
|
||
|
||
/* 容器 - 去除背景,融入父级卡片 */
|
||
.choice-grid-container {
|
||
padding: $spacing-xs 0;
|
||
background: transparent;
|
||
}
|
||
|
||
/* 加载和空状态 */
|
||
.loading-state, .empty-state {
|
||
text-align: center;
|
||
padding: 80rpx 0;
|
||
color: $text-placeholder;
|
||
font-size: $font-sm;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
|
||
&::before {
|
||
content: '📦';
|
||
font-size: 60rpx;
|
||
opacity: 0.5;
|
||
animation: float 3s ease-in-out infinite;
|
||
}
|
||
}
|
||
|
||
/* 网格包装 */
|
||
.grid-wrapper {
|
||
padding: 0 20rpx 140rpx; /* 减少底部padding */
|
||
}
|
||
|
||
/* 号码网格 - 调整为更合理的列数,适配不同屏幕 */
|
||
.choices-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, 1fr); /* 默认6列,更大尺寸 */
|
||
gap: 16rpx;
|
||
padding: 10rpx 0;
|
||
animation: scaleIn 0.4s ease-out backwards;
|
||
}
|
||
|
||
/* 单个号码格子 */
|
||
.choice-item {
|
||
aspect-ratio: 1;
|
||
background: $bg-card;
|
||
border: 1rpx solid $border-color-light;
|
||
border-radius: $radius-md;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||
box-shadow: $shadow-sm;
|
||
|
||
/* 票据孔装饰 */
|
||
&::before, &::after {
|
||
content: '';
|
||
position: absolute;
|
||
width: 8rpx;
|
||
height: 8rpx;
|
||
background: $bg-page; /* 与背景色一致 */
|
||
border-radius: 50%;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
opacity: 0.5;
|
||
}
|
||
&::before { left: -4rpx; }
|
||
&::after { right: -4rpx; }
|
||
}
|
||
|
||
.choice-item:active {
|
||
transform: scale(0.92);
|
||
}
|
||
|
||
/* 号码文字 */
|
||
.choice-number {
|
||
font-size: 28rpx;
|
||
font-weight: 800;
|
||
color: $text-sub;
|
||
z-index: 1;
|
||
font-family: 'DIN Alternate', sans-serif;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
/* 状态文字 - 简化为小点或隐藏 */
|
||
.choice-status {
|
||
display: none;
|
||
}
|
||
|
||
/* ============= 状态样式 ============= */
|
||
|
||
/* 可选状态 */
|
||
.is-available {
|
||
background: #FFFFFF;
|
||
|
||
&:hover {
|
||
border-color: $brand-primary;
|
||
}
|
||
}
|
||
|
||
/* 已售状态 */
|
||
.is-sold {
|
||
color: $text-disabled;
|
||
border-color: $border-color-light;
|
||
background: $bg-secondary;
|
||
opacity: 0.8;
|
||
pointer-events: none;
|
||
|
||
&::before {
|
||
content: 'SOLD';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%) rotate(-30deg);
|
||
font-size: 20rpx;
|
||
font-weight: 900;
|
||
color: $text-disabled;
|
||
opacity: 0.2;
|
||
letter-spacing: 2rpx;
|
||
border: 2rpx solid $text-disabled;
|
||
padding: 2rpx 6rpx;
|
||
border-radius: 4rpx;
|
||
z-index: 0;
|
||
}
|
||
}
|
||
|
||
.is-sold .choice-number {
|
||
color: $text-disabled;
|
||
opacity: 0.3;
|
||
}
|
||
|
||
/* 选中状态 - 橙色高亮 */
|
||
.is-selected {
|
||
background: $gradient-brand;
|
||
border-color: transparent;
|
||
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.3);
|
||
transform: scale(1.08);
|
||
z-index: 2;
|
||
|
||
&::before, &::after {
|
||
display: none; /* 选中状态隐藏票据孔 */
|
||
}
|
||
}
|
||
.is-selected .choice-number {
|
||
color: #FFFFFF;
|
||
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));
|
||
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;
|
||
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: 28rpx;
|
||
color: $text-main;
|
||
display: flex;
|
||
align-items: baseline;
|
||
font-weight: 800;
|
||
}
|
||
.highlight {
|
||
color: $brand-primary;
|
||
font-weight: 900;
|
||
font-size: 40rpx;
|
||
margin: 0 8rpx;
|
||
font-family: 'DIN Alternate', sans-serif;
|
||
}
|
||
|
||
/* 按钮组 */
|
||
.action-buttons {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
/* 通用按钮样式 */
|
||
.btn-common {
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
padding: 0 56rpx;
|
||
border-radius: 999rpx;
|
||
font-size: 30rpx;
|
||
font-weight: 900;
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||
border: none;
|
||
|
||
&::after {
|
||
border: none;
|
||
}
|
||
|
||
&:active {
|
||
transform: scale(0.92);
|
||
}
|
||
}
|
||
|
||
/* 购买按钮 - 品牌渐变 + 流光 */
|
||
.btn-buy {
|
||
background: $gradient-brand !important;
|
||
color: #FFFFFF !important;
|
||
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35);
|
||
position: relative;
|
||
overflow: hidden;
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: -50%;
|
||
left: -150%;
|
||
width: 200%;
|
||
height: 200%;
|
||
background: linear-gradient(
|
||
120deg,
|
||
rgba(255, 255, 255, 0) 30%,
|
||
rgba(255, 255, 255, 0.4) 50%,
|
||
rgba(255, 255, 255, 0) 70%
|
||
);
|
||
transform: rotate(25deg);
|
||
animation: btnShine 4s infinite cubic-bezier(0.19, 1, 0.22, 1);
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
/* 随机按钮 - 轻量化设计 */
|
||
.btn-random {
|
||
background: #1A1A1A !important;
|
||
color: $accent-gold !important;
|
||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.15);
|
||
|
||
&:active {
|
||
background: #333 !important;
|
||
}
|
||
}
|
||
|
||
@keyframes btnShine {
|
||
0% { left: -150%; }
|
||
100% { left: 150%; }
|
||
}
|
||
|
||
@keyframes slideUp {
|
||
from { transform: translateY(120rpx); opacity: 0; }
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
|
||
@keyframes scaleIn {
|
||
from { transform: scale(0.95); opacity: 0; }
|
||
to { transform: scale(1); opacity: 1; }
|
||
}
|
||
|
||
@keyframes float {
|
||
0%, 100% { transform: translateY(0); }
|
||
50% { transform: translateY(-10rpx); }
|
||
}
|
||
</style>
|