flaunt-web/src/components/PaymentDialog.vue

876 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<BaseDialog v-model="visible" width="1000px" class="payment-base-dialog">
<div class="recharge-dialog">
<!-- 头部用户信息 -->
<div class="user-header">
<el-avatar :size="45"
:src="userInfo.avater || userInfo.avatar || userInfo.avatar_url || userStore.avatarUrl || undefined">
{{ (userInfo.name || userInfo.username || userStore.username || 'U').charAt(0).toUpperCase() }}
</el-avatar>
<div class="user-meta">
<span class="username">{{ userInfo.name || userInfo.username || userStore.username || '用户' }}</span>
<span class="vip-status" :class="{ expired: !isVip }">
{{ isVip ? `会员 ${userInfo.membership_expire_at}到期` : '未开通会员' }}
</span>
</div>
</div>
<div class="dialog-body">
<!-- 左侧面板 -->
<div class="left-panel">
<div v-if="!loading && productList.length" class="plans-scroll" :class="{ 'is-disabled': paying }"
ref="scrollContainer" @mousedown="onMouseDown" @mouseleave="onMouseLeave" @mouseup="onMouseUp"
@mousemove="onMouseMove">
<div v-for="(item, idx) in productList" :key="item.goods_id" class="plan-card"
:class="{ active: selectedProductId === item.goods_id }" @click="handleProductChange(item.goods_id)">
<div class="plan-name">{{ item.goods_name }}</div>
<div class="plan-original">&yen;{{ item.origin_price }}</div>
<div class="plan-price"><span class="currency">&yen;</span>{{ item.price }}</div>
</div>
</div>
<div v-else-if="loading" class="loading-wrap">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>加载商品...</span>
</div>
<!-- 手写切换加载蒙层 -->
<transition name="fade">
<div v-if="switchingProduct" class="custom-loading-mask">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>切换中...</span>
</div>
</transition>
<!-- 会员权益 -->
<div class="privilege-section">
<div class="privilege-title">
<img style="width: 171px;height: 18px;" src="@/public/rechargeDialog/huiyuanquanyi.png" alt="">
</div>
<div class="privilege-grid">
<div class="scroll-row">
<div class="scroll-content right-to-left">
<div class="scroll-group">
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon1.png" alt=""> 无水印高清下载</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon2.png" alt=""> 模拟微信</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon3.png" alt=""> 工资单</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon4.png" alt=""> 证件模拟</div>
</div>
<div class="scroll-group">
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon1.png" alt=""> 无水印高清下载</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon2.png" alt=""> 模拟微信</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon3.png" alt=""> 工资单</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon4.png" alt=""> 证件模拟</div>
</div>
</div>
</div>
<div class="scroll-row">
<div class="scroll-content left-to-right">
<div class="scroll-group">
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon5.png" alt=""> 模拟飞机票</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon6.png" alt=""> 模拟高铁票</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon7.png" alt=""> 模拟购物订单</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon8.png" alt=""> 我的零钱</div>
</div>
<div class="scroll-group">
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon5.png" alt=""> 模拟飞机票</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon6.png" alt=""> 模拟高铁票</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon7.png" alt=""> 模拟购物订单</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon8.png" alt=""> 我的零钱</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧毛玻璃面板 -->
<div class="right-panel">
<div class="price-display">
<span class="currency">&yen;</span>
<span class="price-num">{{ Number(currentPrice).toFixed(2) || '0.00' }}</span>
</div>
<div class="discount-tag" v-if="currentOriginalPrice">
已优惠 &yen;{{ (currentOriginalPrice - currentPrice) }}
</div>
<div class="qr-box">
<img v-if="qrCode" :src="qrCode" alt="支付二维码" class="qr-img" />
<div v-else-if="paying || loading" class="qr-placeholder loading">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>生成支付码...</span>
</div>
<div v-else class="qr-placeholder error" @click="handlePay">
<el-icon>
<RefreshRight />
</el-icon>
<span>获取失败,点击重试</span>
</div>
</div>
<div class="pay-methods">
<div class="method-btn" :class="{ active: payMethod === 'weixin' }"
@click="handlePayMethodChange('weixin')">
<img class="icon" src="@/public/rechargeDialog/wxpay.png" alt="微信支付">
微信支付
</div>
<div class="method-btn alipay" :class="{ active: payMethod === 'alipay' }"
@click="handlePayMethodChange('alipay')">
<img class="icon" src="@/public/rechargeDialog/alipay.png" alt="支付宝">
支付宝支付
</div>
</div>
<div class="footer-tips">
<p>支付即视为同意用户协议</p>
<p>如遇到支付问题在线反馈</p>
</div>
</div>
</div>
</div>
</BaseDialog>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { Loading, RefreshRight, Download, ChatDotRound, Tickets, Postcard, Position, Van, ShoppingBag, Wallet } from '@element-plus/icons-vue'
import authService from '@/api/authService.js'
import { useUserStore } from '@/stores/user'
const emit = defineEmits(['success'])
const userStore = useUserStore()
const visible = ref(false)
const selectedProductId = ref(null)
const paying = ref(false)
const switchingProduct = ref(false)
const qrCode = ref('')
const loading = ref(false)
const productList = ref([])
const payMethod = ref('weixin')
let pollTimer = null
let orderVersion = 0
let activeOrderNo = ''
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
// 拖拽滚动相关状态
const scrollContainer = ref(null)
const isDragging = ref(false)
const startX = ref(0)
const scrollLeft = ref(0)
let hasDragged = false
const userInfo = computed(() => ({
username: userStore.username,
avatar_url: userStore.avatarUrl || '',
membership_expire_at: userStore.membershipExpireAt || null,
}))
const isVip = computed(() => userStore.isVip)
const currentPrice = computed(() => {
const p = productList.value.find(i => i.goods_id === selectedProductId.value)
return p ? p.price : ''
})
const currentOriginalPrice = computed(() => {
const p = productList.value.find(i => i.goods_id === selectedProductId.value)
return p ? (p.origin_price || '') : ''
})
function open() {
stopPolling()
orderVersion++
activeOrderNo = ''
visible.value = true
selectedProductId.value = null
qrCode.value = ''
paying.value = false
switchingProduct.value = false
loadProducts()
}
async function loadProducts() {
loading.value = true
try {
const res = await authService.getGoods()
console.log('商品列表', res)
productList.value = Array.isArray(res.data) ? res.data : []
if (productList.value.length > 0) {
// 默认选中第一个商品
selectedProductId.value = productList.value[0].goods_id
} else {
ElMessage.warning('暂无可售商品')
}
} catch (e) {
ElMessage.error(e?.response?.data?.error || '加载商品失败')
productList.value = []
} finally {
// 优先关闭列表 Loading让商品卡片先行渲染展示
loading.value = false
}
// 列表渲染展示后,再触发支付二维码的异步获取
if (selectedProductId.value) {
handlePay()
}
}
/**
* 智能格式化支付二维码图片地址
* 解决微信支付二维码加载不出来的常见原因:
* 1. 微信原生支付协议 (weixin://) 无法直接用 <img> 渲染,需要转换为二维码图片 URL
* 2. 纯 Base64 字符串缺失 data:image/png;base64, 前缀;
* 3. 兼容 res.data.qrcodeBase64、res.data.qrcode 等不同字段。
*/
function getQrCodeUrl(resData) {
if (!resData) return ''
const dataObj = resData.data || resData
// 提取可能的字段
const raw = dataObj.qrcodeBase64 || dataObj.qrcode_base64 || dataObj.qrcode || dataObj.payParam || (typeof dataObj === 'string' ? dataObj : '')
if (!raw) return ''
// 1. 已经是完整的 Data URI 或 HTTP(S) 图片链接
if (raw.startsWith('data:image/') || raw.startsWith('http://') || raw.startsWith('https://')) {
return raw
}
// 2. 纯 Base64 字符串(补齐前缀)
if (!raw.startsWith('weixin://') && !raw.startsWith('alipays://') && raw.length > 100) {
return `data:image/png;base64,${raw.replace(/\s/g, '')}`
}
// 3. 微信协议链接 (weixin://wxpay/...) 或网页链接,动态转换为可渲染的二维码图片
if (raw.startsWith('weixin://') || raw.startsWith('alipays://') || raw.includes('://')) {
return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(raw)}`
}
return raw
}
async function handlePay() {
if (!selectedProductId.value || paying.value) return
stopPolling()
orderVersion++
const currentVersion = orderVersion
paying.value = true
qrCode.value = ''
activeOrderNo = ''
const data = {
"goods_id": selectedProductId.value, // 从商品列表获取
"pay_type": payMethod.value, // 支付方式weixin/alipay/apple/bank/balance
"pay_source": "web", // 默认app支持app、jsapi小程序、web、h5
"source": "web", // 下单商品位置,业务自定义
"extra": {
qrCodeImgUrl: "true",
}
}
try {
const res = await authService.createOrder(data)
console.log('创建订单响应结果:', res)
// 版本锁校验:若当前请求版本已被后续操作覆盖,则丢弃过时的响应
if (currentVersion !== orderVersion) {
console.warn('丢弃已失效过时的订单响应')
return
}
const orderData = res.data || res
if (payMethod.value === 'alipay') {
qrCode.value = orderData.payParam || ''
} else {
// 使用智能格式化获取可供 <img> 渲染的二维码图片地址
qrCode.value = getQrCodeUrl(orderData)
}
const orderNo = res.order_no || orderData.order_no || orderData.orderId || orderData.order_id
if (orderNo) {
activeOrderNo = orderNo
startPolling(orderNo, currentVersion)
}
} catch (e) {
if (currentVersion === orderVersion) {
qrCode.value = ''
ElMessage.error(e?.response?.data?.error || '创建订单失败,请点击重试')
}
} finally {
if (currentVersion === orderVersion) {
paying.value = false
switchingProduct.value = false
}
}
}
function handlePayMethodChange(method) {
if (paying.value || payMethod.value === method) return
payMethod.value = method
handlePay()
}
function handleProductChange(id) {
if (hasDragged || paying.value || selectedProductId.value === id) return
selectedProductId.value = id
switchingProduct.value = true
handlePay()
}
// 拖拽滚动逻辑
function onMouseDown(e) {
if (!scrollContainer.value) return
isDragging.value = true
hasDragged = false
scrollContainer.value.style.cursor = 'grabbing'
startX.value = e.pageX - scrollContainer.value.offsetLeft
scrollLeft.value = scrollContainer.value.scrollLeft
}
function onMouseLeave() {
isDragging.value = false
if (scrollContainer.value) scrollContainer.value.style.cursor = 'grab'
}
function onMouseUp() {
isDragging.value = false
if (scrollContainer.value) scrollContainer.value.style.cursor = 'grab'
}
function onMouseMove(e) {
if (!isDragging.value || !scrollContainer.value) return
e.preventDefault()
const x = e.pageX - scrollContainer.value.offsetLeft
const walk = (x - startX.value) * 1.5 // 滚动速度倍率
if (Math.abs(walk) > 5) {
hasDragged = true
}
scrollContainer.value.scrollLeft = scrollLeft.value - walk
}
function startPolling(orderNo, currentVersion) {
stopPolling()
let count = 0
pollTimer = setInterval(async () => {
// 双重失效保护:若版本变动或关联的订单号不匹配,强行关闭轮询
if (currentVersion !== orderVersion || orderNo !== activeOrderNo) {
stopPolling()
return
}
count++
try {
const res = await authService.getOrder({ order_id: orderNo })
console.log('轮询查询订单响应结果:', res, '订单号', orderNo)
if (res.code == 0 && res?.data?.status ==
2) {
stopPolling()
ElMessage.success('支付成功,会员已开通')
setTimeout(() => {
visible.value = false
emit('success')
}, 1500)
}
} catch (e) {
console.error('轮询查询订单失败:', e)
}
if (count > 120) {
stopPolling()
ElMessage.warning('轮询超时,请刷新页面查看')
}
}, 3000)
}
watch(visible, (val) => {
if (!val) {
stopPolling()
orderVersion++
activeOrderNo = ''
}
})
onUnmounted(() => {
stopPolling()
})
defineExpose({ open })
</script>
<style lang="scss" scoped>
.recharge-dialog {
width: 1000px;
height: 704px;
background-image: url('/src/public/rechargeDialog/bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
flex-direction: column;
position: relative;
}
.user-header {
position: absolute;
top: 124px;
left: 48px;
display: flex;
align-items: center;
gap: 16px;
color: #fff;
.username {
font-weight: 500;
font-size: 18px;
color: #FFFFFF;
line-height: 18px;
}
.vip-status {
font-size: 12px;
opacity: 0.8;
}
.user-meta {
display: flex;
flex-direction: column;
justify-content: center;
.vip-status {
margin-top: 8px;
font-weight: 500;
font-size: 14px;
color: #FFFFFF;
line-height: 14px;
}
}
}
.dialog-body {
margin-top: 187px;
padding: 0 24px;
display: flex;
justify-content: space-between;
gap: 24px;
}
.left-panel {
position: relative;
flex-shrink: 0;
width: 677px;
height: 493px;
padding: 44px 46px;
box-sizing: border-box;
.custom-loading-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(245, 245, 255, 0.75);
backdrop-filter: blur(2px);
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
z-index: 10;
color: #8B70FF;
font-size: 14px;
font-weight: 500;
pointer-events: auto;
.el-icon {
font-size: 24px;
}
}
}
.plans-scroll {
position: relative;
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 2px;
cursor: grab;
user-select: none;
border-radius: 16px;
&.is-disabled {
pointer-events: none;
}
&::-webkit-scrollbar {
display: none;
}
}
.plan-card {
flex: 0 0 160px;
height: 210px;
background: #F5F5FF;
border-radius: 16px 16px 16px 16px;
border: 2px solid #BFC8F8;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding-top: 30px;
transition: all 0.3s;
.plan-name {
font-size: 24px;
color: #26106B;
line-height: 21px;
font-weight: 500;
}
.plan-original {
width: 100%;
padding-left: 33px;
font-size: 20px;
color: #26106B;
line-height: 20px;
text-decoration: line-through;
margin-top: 6px;
margin-bottom: 50px;
}
.plan-price {
font-size: 55px;
color: #26106B;
line-height: 52px;
font-weight: 500;
.currency {
font-size: 26px;
color: #26106B;
line-height: 26px;
font-weight: 700;
}
}
&.active {
background: url('/src/public/rechargeDialog/active-product-bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: relative;
background-clip: padding-box;
border: none;
&::before {
content: '';
position: absolute;
top: -2px;
right: -2px;
bottom: -2px;
left: -2px;
z-index: -1;
border-radius: 14px;
background: linear-gradient(135deg, #B17DFF, #50E5FF);
}
}
}
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 220px;
color: #999;
}
.privilege-section {
margin-top: 38px;
.privilege-title {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 34px;
.text {
font-size: 16px;
font-weight: bold;
color: #8B70FF;
}
.line {
width: 40px;
height: 1px;
background: #E0E0E0;
}
}
.privilege-grid {
display: flex;
flex-direction: column;
gap: 14px;
overflow: hidden;
width: 100%;
.scroll-row {
width: 100%;
overflow: hidden;
display: flex;
}
.scroll-content {
display: flex;
width: max-content;
&.right-to-left {
animation: scroll-rl 15s linear infinite;
}
&.left-to-right {
transform: translateX(-50%);
animation: scroll-lr 15s linear infinite;
}
&:hover {
animation-play-state: paused;
}
}
.scroll-group {
display: flex;
gap: 10px;
padding-right: 10px;
}
.p-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
font-size: 14px;
color: #1A1A1A;
line-height: 14px;
white-space: nowrap;
flex-shrink: 0;
width: 145px;
.icon {
width: 46px;
height: 46px;
}
.el-icon {
font-size: 20px;
color: #8B70FF;
}
}
}
@keyframes scroll-rl {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@keyframes scroll-lr {
0% {
transform: translateX(-50%);
}
100% {
transform: translateX(0);
}
}
}
.right-panel {
width: 251px;
height: 493px;
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
padding: 53px 16px 22px;
box-sizing: border-box;
.price-display {
color: #fff;
.currency {
font-size: 32px;
color: #FFFFFF;
line-height: 48px;
margin-right: 2px;
}
.price-num {
font-size: 52px;
color: #FFFFFF;
line-height: 48px;
}
}
.discount-tag {
padding-inline: 20px;
min-width: 120px;
height: 44px;
background: url('/src/public/rechargeDialog/discount-tag-bg.png');
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
color: #fff;
padding: 0 12px;
border-radius: 20px;
margin-bottom: 28px;
font-weight: 700;
font-size: 18px;
line-height: 48px;
color: #FFFFFF;
white-space: nowrap;
}
.qr-box {
width: 158px;
height: 158px;
background: #fff;
border-radius: 10px;
padding: 15px;
margin-bottom: 20px;
box-sizing: border-box;
.qr-img {
width: 100%;
height: 100%;
}
.qr-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #999;
font-size: 12px;
background: #f5f5f5;
border-radius: 10px;
padding: 8px;
box-sizing: border-box;
text-align: center;
&.error {
cursor: pointer;
color: #ff4d4f;
transition: background-color 0.2s;
&:hover {
background-color: #ffeef0;
}
.el-icon {
font-size: 20px;
}
}
&.loading {
cursor: wait;
color: #8B70FF;
.el-icon {
font-size: 22px;
}
}
}
}
.pay-methods {
display: flex;
gap: 12px;
margin-bottom: auto;
.method-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
font-weight: 700;
font-size: 14px;
color: #FFFFFF;
line-height: 14px;
white-space: nowrap;
cursor: pointer;
border: 1px solid transparent;
.icon {
width: 18px;
height: 18px;
}
}
.active {
background: #FFFFFF40;
}
}
.footer-tips {
text-align: center;
font-weight: 500;
font-size: 12px;
color: #FFFFFFAA;
line-height: 24px;
}
}
.payment-base-dialog {
:deep(.el-dialog) {
padding: 0 !important;
background-color: transparent;
box-shadow: 0 0 0 0 #00000000;
}
:deep(.base-dialog .close) {
top: 80px !important;
right: -32px !important;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>