未推送到的文件

This commit is contained in:
ty200947752 2025-12-15 11:41:45 +08:00
parent b79cd37932
commit 5298ed1acf
2 changed files with 682 additions and 0 deletions

271
components/PaymentPopup.vue Normal file
View File

@ -0,0 +1,271 @@
<template>
<view v-if="visible" class="payment-popup-mask" @tap="handleMaskClick">
<view class="payment-popup-content" @tap.stop>
<!-- 顶部提示 -->
<view class="risk-warning">
<text>盲盒具有随机性请理性消费购买即表示同意</text>
<text class="agreement-link" @tap="openAgreement">购买协议</text>
</view>
<view class="popup-header">
<text class="popup-title">确认支付</text>
<view class="close-icon" @tap="handleClose">×</view>
</view>
<view class="popup-body">
<view class="amount-section" v-if="amount !== undefined && amount !== null">
<text class="label">支付金额</text>
<text class="amount">¥{{ amount }}</text>
</view>
<view class="form-item">
<text class="label">优惠券</text>
<picker
mode="selector"
:range="coupons"
range-key="name"
@change="onCouponChange"
:value="couponIndex"
:disabled="!coupons || coupons.length === 0"
>
<view class="picker-display">
<text v-if="selectedCoupon" class="selected-text">{{ selectedCoupon.name }} (-¥{{ selectedCoupon.amount }})</text>
<text v-else-if="!coupons || coupons.length === 0" class="placeholder">暂无优惠券可用</text>
<text v-else class="placeholder">请选择优惠券</text>
<text class="arrow"></text>
</view>
</picker>
</view>
<view class="form-item" v-if="showCards">
<text class="label">道具卡</text>
<picker
mode="selector"
:range="propCards"
range-key="name"
@change="onCardChange"
:value="cardIndex"
:disabled="!propCards || propCards.length === 0"
>
<view class="picker-display">
<text v-if="selectedCard" class="selected-text">{{ selectedCard.name }}</text>
<text v-else-if="!propCards || propCards.length === 0" class="placeholder">暂无道具卡可用</text>
<text v-else class="placeholder">请选择道具卡</text>
<text class="arrow"></text>
</view>
</picker>
</view>
</view>
<view class="popup-footer">
<button class="btn-cancel" @tap="handleClose">取消</button>
<button class="btn-confirm" @tap="handleConfirm">确认支付</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
amount: { type: [Number, String], default: 0 },
coupons: { type: Array, default: () => [] },
propCards: { type: Array, default: () => [] },
showCards: { type: Boolean, default: true }
})
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
const couponIndex = ref(-1)
const cardIndex = ref(-1)
const selectedCoupon = computed(() => {
if (couponIndex.value >= 0 && props.coupons[couponIndex.value]) {
return props.coupons[couponIndex.value]
}
return null
})
const selectedCard = computed(() => {
if (cardIndex.value >= 0 && props.propCards[cardIndex.value]) {
return props.propCards[cardIndex.value]
}
return null
})
watch(() => props.visible, (val) => {
if (val) {
couponIndex.value = -1
cardIndex.value = -1
}
})
function onCouponChange(e) {
couponIndex.value = e.detail.value
}
function onCardChange(e) {
cardIndex.value = e.detail.value
}
function openAgreement() {
uni.navigateTo({
url: '/pages/agreement/purchase' //
})
}
function handleMaskClick() {
handleClose()
}
function handleClose() {
emit('update:visible', false)
emit('cancel')
}
function handleConfirm() {
emit('confirm', {
coupon: selectedCoupon.value,
card: props.showCards ? selectedCard.value : null
})
}
</script>
<style scoped>
.payment-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 999;
display: flex;
align-items: flex-end;
}
.payment-popup-content {
width: 100%;
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
padding: 30rpx;
padding-bottom: calc(30rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
}
.risk-warning {
background-color: #fffbe6;
color: #ed6a0c;
font-size: 24rpx;
padding: 16rpx 24rpx;
border-radius: 8rpx;
margin-bottom: 24rpx;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
line-height: 1.4;
text-align: center;
}
.agreement-link {
color: #1890ff;
text-decoration: underline;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40rpx;
}
.popup-title {
font-size: 32rpx;
font-weight: bold;
}
.close-icon {
position: absolute;
right: 30rpx;
font-size: 40rpx;
color: #999;
line-height: 1;
padding: 10rpx;
}
.popup-body {
padding: 30rpx;
}
.amount-section {
text-align: center;
margin-bottom: 40rpx;
}
.amount-section .label {
font-size: 28rpx;
color: #666;
margin-right: 10rpx;
}
.amount-section .amount {
font-size: 48rpx;
font-weight: bold;
color: #ff4d4f;
}
.form-item {
margin-bottom: 30rpx;
}
.form-item .label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 16rpx;
}
.picker-display {
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 28rpx;
background: #f9f9f9;
}
.selected-text {
color: #333;
}
.placeholder {
color: #999;
}
.arrow {
color: #ccc;
width: 16rpx;
height: 16rpx;
border-right: 2rpx solid #ccc;
border-bottom: 2rpx solid #ccc;
transform: rotate(-45deg);
margin-right: 8rpx;
}
.popup-footer {
display: flex;
border-top: 1rpx solid #eee;
}
.btn-cancel, .btn-confirm {
flex: 1;
border: none;
background: #fff;
border-radius: 0;
font-size: 30rpx;
padding: 24rpx 0;
line-height: 1.5;
}
.btn-cancel::after, .btn-confirm::after {
border: none;
}
.btn-cancel {
color: #666;
border-right: 1rpx solid #eee;
}
.btn-confirm {
color: #007AFF;
font-weight: bold;
}
</style>

View File

@ -0,0 +1,411 @@
<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-random" @tap="handleRandomOne">随机一发</button>
<button v-else class="btn-buy" @tap="handleBuy">去支付</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'
const props = defineProps({
activityId: { type: [String, Number], required: true },
issueId: { type: [String, Number], required: true },
pricePerDraw: { type: Number, default: 0 } //
})
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)
})
watch(() => props.issueId, (newVal) => {
if (newVal) {
loadChoices()
selectedItems.value = []
}
})
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 (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 (selectedItems.value.length === 0) return
paymentVisible.value = true
fetchCoupons()
}
function handleRandomOne() {
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) {
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
}
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 scoped>
.choice-grid-container {
padding: 20rpx;
}
.loading-state, .empty-state {
text-align: center;
padding: 40rpx;
color: #999;
}
.choices-grid {
display: grid;
grid-template-columns: repeat(5, 1fr); /* 一行5个 */
gap: 16rpx;
margin-bottom: 120rpx; /* 留出底部操作栏空间 */
}
.choice-item {
aspect-ratio: 1;
background: #fff;
border: 2rpx solid #e0e0e0;
border-radius: 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.choice-number {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.choice-status {
font-size: 20rpx;
margin-top: 4rpx;
color: #666;
}
/* 状态样式 */
.is-available {
background: #fff;
}
.is-sold {
background: #f5f5f5;
border-color: #eee;
opacity: 0.6;
}
.is-sold .choice-number {
color: #ccc;
text-decoration: line-through;
}
.is-selected {
background: #e6f7ff;
border-color: #1890ff;
}
.is-selected .choice-number {
color: #1890ff;
}
.is-selected .choice-status {
color: #1890ff;
}
/* 底部操作栏 */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 20rpx 30rpx;
box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.selection-info {
font-size: 28rpx;
color: #333;
}
.highlight {
color: #ff4d4f;
font-weight: bold;
margin: 0 4rpx;
}
.btn-buy {
background: #ff4d4f;
color: #fff;
border-radius: 40rpx;
padding: 0 60rpx;
height: 80rpx;
line-height: 80rpx;
font-size: 30rpx;
margin: 0;
}
.btn-random {
background: #007AFF;
color: #fff;
border-radius: 40rpx;
padding: 0 60rpx;
height: 80rpx;
line-height: 80rpx;
font-size: 30rpx;
margin: 0;
}
</style>