Files
device-voice-h5/pages/my-wallet/my-wallet.vue
sexygoat 3f997063f4
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m37s
fix: 优化体验
2026-05-09 16:14:06 +08:00

983 lines
24 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>
<view class="container">
<view class="pd-20">
<view class="wallet-card">
<view class="wallet-header">
<view class="header-left">
<view class="balance-label">账户余额</view>
<view class="balance-amount">¥{{ formatMoney(walletDetail.balance) }}</view>
</view>
<button class="btn-recharge" @tap="showRechargeModal = true">充值</button>
</view>
<view class="wallet-info">
<view class="info-item">
<view class="info-label">冻结金额</view>
<view class="info-value">¥{{ formatMoney(walletDetail.frozen_balance) }}</view>
</view>
</view>
</view>
<view class="tab-section">
<view class="tab-header">
<view class="tab-indicator" :style="{ left: currentTab === 0 ? '8rpx' : '50%' }"></view>
<view class="tab-item" :class="{ active: currentTab === 0 }" @tap="onTabTap(0)">
<text>充值订单</text>
</view>
<view class="tab-item" :class="{ active: currentTab === 1 }" @tap="onTabTap(1)">
<text>钱包流水</text>
</view>
</view>
<swiper class="tab-swiper" :current="currentTab" @change="onSwiperChange" :circular="false"
:acceleration="true">
<swiper-item class="swiper-item-content">
<scroll-view scroll-y class="list-content" @scrolltolower="loadMoreRecharge">
<view v-if="rechargeList.length === 0 && !rechargeLoading" class="empty-state">
<view class="empty-icon">📋</view>
<view class="empty-title">暂无充值订单</view>
</view>
<view v-else>
<view class="card record-card" v-for="(item, index) in rechargeList" :key="index">
<view class="record-header flex-row-sb">
<view class="record-no">{{ item.recharge_order_no || item.recharge_no }}</view>
<view class="tag-apple" :class="getRechargeStatusClass(item.status)">
{{ item.status_name }}</view>
</view>
<view class="record-info">
<view class="info-row flex-row-sb">
<view class="info-label">充值金额</view>
<view class="info-value text-danger">+¥{{ formatMoney(item.amount) }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">支付方式</view>
<view class="info-value">{{ item.payment_method || '-' }}</view>
</view>
<view v-if="hasAutoPurchaseStatus(item)" class="info-row flex-row-sb">
<view class="info-label">自动购包</view>
<view class="info-value">
{{ getAutoPurchaseStatusText(item.auto_purchase_status) }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">创建时间</view>
<view class="info-value">{{ item.created_at }}</view>
</view>
</view>
<view v-if="item.status === 0" class="record-actions">
<button class="btn-pay" :disabled="rechargeOrderSubmittingKey !== null" @tap="handleRechargePayment(item)">
{{ isRechargeOrderSubmitting(item) ? '处理中...' : '立即支付' }}
</button>
</view>
</view>
<view class="load-more" v-if="rechargeList.length > 0">
<text v-if="rechargeLoading">加载中...</text>
<text v-else-if="rechargeNoMore"> 没有更多了 </text>
</view>
</view>
</scroll-view>
</swiper-item>
<swiper-item class="swiper-item-content">
<scroll-view scroll-y class="list-content" @scrolltolower="loadMoreTransaction">
<view v-if="transactionList.length === 0 && !transactionLoading" class="empty-state">
<view class="empty-icon">💰</view>
<view class="empty-title">暂无钱包流水</view>
</view>
<view v-else>
<view class="card record-card" v-for="(item, index) in transactionList" :key="index">
<view class="record-header flex-row-sb">
<view class="record-type">{{ item.type_name }}</view>
<view class="record-amount"
:class="item.amount >= 0 ? 'text-success' : 'text-danger'">
{{ item.amount >= 0 ? '+' : '' }}{{ formatMoney(item.amount) }}
</view>
</view>
<view class="record-info">
<view class="info-row flex-row-sb">
<view class="info-label">变动后余额</view>
<view class="info-value">¥{{ formatMoney(item.balance_after) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="item.remark">
<view class="info-label">备注</view>
<view class="info-value">{{ item.remark }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">支付时间</view>
<view class="info-value">{{ item.created_at }}</view>
</view>
</view>
</view>
<view class="load-more" v-if="transactionList.length > 0">
<text v-if="transactionLoading">加载中...</text>
<text v-else-if="transactionNoMore"> 没有更多了 </text>
</view>
</view>
</scroll-view>
</swiper-item>
</swiper>
<!-- 充值弹窗 -->
<view>
<up-popup :show="showRechargeModal" mode="center" @close="showRechargeModal=false">
<view class="recharge-popup">
<view class="popup-header">
<view class="popup-title">账户充值</view>
<view class="popup-close" @tap="showRechargeModal=false">×</view>
</view>
<view class="recharge-amounts">
<view class="amount-item" v-for="item in rechargeAmounts" :key="item.value"
:class="{ active: selectedAmount === item.value }" @tap="selectAmount(item.value)">
<text class="amount-value">¥{{ formatDisplayMoney(item.label) }}</text>
</view>
<view class="amount-item custom" :class="{ active: isCustomAmount }"
@tap="selectCustomAmount">
<text class="amount-value">自定义</text>
</view>
</view>
<view class="custom-input" v-if="isCustomAmount">
<view class="input-label">请输入充值金额</view>
<up-input v-model="customAmount" type="number" placeholder="最低1元" border="surround" />
</view>
<view class="popup-footer">
<button class="btn-apple btn-secondary" @tap="showRechargeModal=false">取消</button>
<button class="btn-apple btn-primary" :disabled="rechargeSubmitting" @tap="confirmRecharge">
{{ rechargeSubmitting ? '处理中...' : '确认充值' }}
</button>
</view>
</view>
</up-popup>
</view>
</view>
</view>
</view>
</template>
<script setup>
import {
ref,
reactive,
onMounted
} from 'vue';
import {
walletApi
} from '@/api/index.js';
import {
useUserStore
} from '@/store/index.js';
import { wechatH5Pay, handlePaymentError, showPaymentToast } from '@/utils/payment.js';
const userStore = useUserStore();
let currentTab = ref(0);
const onTabTap = (index) => {
currentTab.value = index;
};
const onSwiperChange = (e) => {
currentTab.value = e.detail.current;
};
const formatMoney = (amount) => {
if (!amount && amount !== 0) return '0.00';
return (amount / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
const formatDisplayMoney = (amount) => {
if (!amount && amount !== 0) return '0';
// 直接格式化显示的数字(已经是元),添加千分号
return parseFloat(amount).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
const formatDateTime = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
};
let walletDetail = reactive({
balance: 0,
frozen_balance: 0,
updated_at: ''
});
let rechargeList = reactive([]);
let rechargeLoading = ref(false);
let rechargeNoMore = ref(false);
let rechargePage = ref(1);
const rechargePageSize = 5;
let transactionList = reactive([]);
let transactionLoading = ref(false);
let transactionNoMore = ref(false);
let transactionPage = ref(1);
const transactionPageSize = 5;
let showRechargeModal = ref(false);
let selectedAmount = ref(null);
let isCustomAmount = ref(false);
let customAmount = ref('');
let rechargeSubmitting = ref(false);
let rechargeOrderSubmittingKey = ref(null);
const rechargeAmounts = [{
value: 1000,
label: '10'
},
{
value: 2000,
label: '20'
},
{
value: 5000,
label: '50'
},
{
value: 10000,
label: '100'
},
{
value: 20000,
label: '200'
},
{
value: 50000,
label: '500'
}
];
const selectAmount = (value) => {
selectedAmount.value = value;
isCustomAmount.value = false;
customAmount.value = '';
};
const selectCustomAmount = () => {
isCustomAmount.value = true;
selectedAmount.value = null;
};
const confirmRecharge = async () => {
if (rechargeSubmitting.value) return;
rechargeSubmitting.value = true;
let amount = 0;
if (isCustomAmount.value) {
// 自定义金额
const customAmountNum = parseFloat(customAmount.value);
if (!customAmountNum || customAmountNum < 1) {
showRechargeModal.value = false;
rechargeSubmitting.value = false;
setTimeout(() => {
uni.showToast({
title: '请输入正确的充值金额',
icon: 'none'
});
}, 50);
return;
}
amount = Math.round(customAmountNum * 100); // 转换为分
} else if (selectedAmount.value) {
// 预设金额
amount = selectedAmount.value;
} else {
showRechargeModal.value = false;
rechargeSubmitting.value = false;
setTimeout(() => {
uni.showToast({
title: '请选择充值金额',
icon: 'none'
});
}, 50);
return;
}
uni.showLoading({
title: '处理中...',
mask: true
});
try {
// 第一步:充值前校验
const checkData = await walletApi.rechargeCheck(userStore.state.identifier);
// 如果需要强制充值且金额小于强制充值金额
if (checkData.need_force_recharge && amount < checkData.force_recharge_amount) {
uni.hideLoading();
uni.showModal({
title: '提示',
content: `${checkData.message || '当前需充值最低金额'} ¥${(checkData.force_recharge_amount / 100).toFixed(2)}`,
showCancel: false
});
return;
}
// 检查金额范围
if (amount < checkData.min_amount || amount > checkData.max_amount) {
uni.hideLoading();
showRechargeModal.value = false;
setTimeout(() => {
uni.showToast({
title: `充值金额范围:¥${(checkData.min_amount / 100).toFixed(2)} - ¥${(checkData.max_amount / 100).toFixed(2)}`,
icon: 'none',
duration: 2500
});
}, 50);
return;
}
// 第二步:创建充值订单
const rechargeData = await walletApi.recharge(
userStore.state.identifier,
amount,
'wechat'
);
uni.hideLoading();
showRechargeModal.value = false;
// 验证支付配置
if (!rechargeData.pay_config || !rechargeData.pay_config.package) {
setTimeout(() => {
uni.showToast({
title: '支付参数获取失败',
icon: 'none'
});
}, 50);
return;
}
// 第三步:调起微信支付
try {
await wechatH5Pay(rechargeData.pay_config);
// 支付成功
showPaymentToast(true, '充值成功');
setTimeout(() => {
loadWalletDetail();
rechargePage.value = 1;
rechargeNoMore.value = false;
loadRechargeList();
}, 1500);
} catch (error) {
// 支付失败或取消
handlePaymentError(error);
}
} catch (e) {
uni.hideLoading();
console.error('充值失败', e);
const errorMsg = e.msg || e.message || '充值失败,请稍后重试';
showRechargeModal.value = false;
setTimeout(() => {
uni.showToast({
title: errorMsg,
icon: 'none'
});
}, 50);
} finally {
rechargeSubmitting.value = false;
}
};
const getRechargeStatusClass = (status) => {
const classMap = {
0: 'tag-warning', // 待支付
1: 'tag-success', // 已支付
2: 'tag-danger', // 已关闭
3: 'tag-danger' // 已退款
};
return classMap[status] || '';
};
const getAutoPurchaseStatusText = (status) => {
const statusMap = {
'pending': '处理中',
'success': '已完成',
'failed': '失败',
'1': '已开启', // 兼容旧版
'0': '未开启' // 兼容旧版
};
return statusMap[status] || '未开启';
};
const hasAutoPurchaseStatus = (item) => Object.prototype.hasOwnProperty.call(item, 'auto_purchase_status') &&
item.auto_purchase_status !== null &&
item.auto_purchase_status !== undefined &&
item.auto_purchase_status !== '';
const getRechargeOrderSubmitKey = (rechargeOrder) => rechargeOrder.recharge_order_no ||
rechargeOrder.recharge_no ||
rechargeOrder.recharge_id ||
rechargeOrder.order_id ||
`${rechargeOrder.amount}-${rechargeOrder.created_at}`;
const isRechargeOrderSubmitting = (rechargeOrder) => rechargeOrderSubmittingKey.value ===
getRechargeOrderSubmitKey(rechargeOrder);
const loadWalletDetail = async () => {
try {
const data = await walletApi.getDetail(userStore.state.identifier);
walletDetail.balance = data.balance || 0;
walletDetail.frozen_balance = data.frozen_balance || 0;
walletDetail.updated_at = data.updated_at || '';
} catch (e) {
console.error('加载钱包详情失败', e);
}
};
const loadRechargeList = async (append = false) => {
if (rechargeLoading.value || rechargeNoMore.value) return;
rechargeLoading.value = true;
try {
// 使用新的充值订单接口
const data = await walletApi.getRechargeOrders(
userStore.state.identifier,
rechargePage.value,
rechargePageSize
);
const newData = (data.items || []).map(item => {
// 格式化时间
let formattedCreatedAt = item.created_at;
if (formattedCreatedAt && formattedCreatedAt.includes('T')) {
formattedCreatedAt = formattedCreatedAt.replace('T', ' ').slice(0, 19);
}
// 后端已返回 status_name优先使用
if (item.status_name) {
return {
...item,
created_at: formattedCreatedAt
};
}
// 兼容旧版本,手动映射
let statusName = '未知';
switch (item.status) {
case 0:
statusName = '待支付';
break;
case 1:
statusName = '已支付';
break;
case 2:
statusName = '已关闭';
break;
case 3:
statusName = '已退款';
break;
}
return {
...item,
status_name: statusName,
created_at: formattedCreatedAt
};
});
if (append) {
rechargeList.push(...newData);
} else {
rechargeList.splice(0, rechargeList.length, ...newData);
}
if (newData.length < rechargePageSize) {
rechargeNoMore.value = true;
} else {
rechargePage.value++;
}
} catch (e) {
console.error('加载充值记录失败', e);
}
rechargeLoading.value = false;
};
const handleRechargePayment = async (rechargeOrder) => {
if (rechargeOrderSubmittingKey.value !== null) return;
rechargeOrderSubmittingKey.value = getRechargeOrderSubmitKey(rechargeOrder);
uni.showLoading({
title: '处理中...',
mask: true
});
try {
// 使用订单金额重新调用充值接口获取支付配置
const rechargeData = await walletApi.recharge(
userStore.state.identifier,
rechargeOrder.amount,
'wechat'
);
uni.hideLoading();
// 验证支付配置
if (!rechargeData.pay_config || !rechargeData.pay_config.package) {
uni.showToast({
title: '支付参数获取失败',
icon: 'none'
});
return;
}
// 调起微信支付
try {
await wechatH5Pay(rechargeData.pay_config);
// 支付成功
showPaymentToast(true, '充值成功');
setTimeout(() => {
loadWalletDetail();
rechargePage.value = 1;
rechargeNoMore.value = false;
loadRechargeList();
}, 1500);
} catch (error) {
// 支付失败或取消
handlePaymentError(error);
}
} catch (e) {
uni.hideLoading();
console.error('获取支付配置失败', e);
const errorMsg = e.msg || e.message || '支付失败,请稍后重试';
uni.showToast({
title: errorMsg,
icon: 'none'
});
} finally {
rechargeOrderSubmittingKey.value = null;
}
};
const loadTransactionList = async (append = false) => {
if (transactionLoading.value || transactionNoMore.value) return;
transactionLoading.value = true;
try {
const data = await walletApi.getTransactions(
userStore.state.identifier,
transactionPage.value,
transactionPageSize
);
const newData = (data.items || []).map(item => {
// 格式化时间
let formattedCreatedAt = item.created_at;
if (formattedCreatedAt && formattedCreatedAt.includes('T')) {
formattedCreatedAt = formattedCreatedAt.replace('T', ' ').slice(0, 19);
}
return {
...item,
type_name: item.type === 'recharge' ? '充值' : item.type === 'purchase' ? '消费' : '其他',
created_at: formattedCreatedAt
};
});
if (append) {
transactionList.push(...newData);
} else {
transactionList.splice(0, transactionList.length, ...newData);
}
if (newData.length < transactionPageSize) {
transactionNoMore.value = true;
} else {
transactionPage.value++;
}
} catch (e) {
console.error('加载钱包流水失败', e);
}
transactionLoading.value = false;
};
const loadMoreRecharge = () => {
if (!rechargeNoMore.value) {
loadRechargeList(true);
}
};
const loadMoreTransaction = () => {
if (!transactionNoMore.value) {
loadTransactionList(true);
}
};
onMounted(() => {
loadWalletDetail();
loadRechargeList();
loadTransactionList();
});
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
max-width: 750rpx;
margin: 0 auto;
padding: 0 !important;
width: 100%;
}
.wallet-card {
background: var(--primary);
border-radius: var(--radius-large);
padding: 40rpx;
color: #fff;
.wallet-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 32rpx;
.header-left {
flex: 1;
.balance-label {
font-size: 26rpx;
opacity: 0.85;
margin-bottom: 12rpx;
font-weight: 500;
}
.balance-amount {
font-size: 64rpx;
font-weight: 700;
letter-spacing: -2rpx;
line-height: 1;
}
}
.btn-recharge {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border: 2rpx solid rgba(255, 255, 255, 0.3);
color: #fff;
font-size: 28rpx;
padding: 0rpx 32rpx;
border-radius: 50rpx;
font-weight: 600;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&::after {
border: none;
}
&:active {
background: rgba(255, 255, 255, 0.3);
transform: scale(0.95);
}
}
}
.wallet-info {
padding-top: 24rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.2);
.info-item {
display: flex;
align-items: center;
justify-content: space-between;
.info-label {
font-size: 24rpx;
opacity: 0.8;
font-weight: 500;
}
.info-value {
font-size: 28rpx;
font-weight: 600;
}
}
}
}
.tab-section {
margin-top: 32rpx;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.tab-header {
flex-shrink: 0;
display: flex;
position: relative;
background: #fff;
border-radius: 16rpx;
padding: 8rpx;
.tab-indicator {
position: absolute;
top: 8rpx;
width: calc(50% - 16rpx);
height: calc(100% - 16rpx);
background: var(--primary);
border-radius: 12rpx;
transition: left 0.3s ease;
z-index: 0;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 0;
font-size: 28rpx;
font-weight: 500;
color: var(--text-tertiary);
position: relative;
z-index: 1;
transition: color 0.3s;
&.active {
color: #fff;
font-weight: 600;
}
}
}
}
.tab-swiper {
margin-top: 20rpx;
min-height: 0;
height: calc(100vh - 500rpx);
}
.swiper-item-content {
height: calc(100vh - 500rpx);
}
.list-content {
height: calc(100vh - 500rpx);
padding-top: 16rpx;
box-sizing: border-box;
}
.record-card {
margin-bottom: 24rpx;
.record-header {
margin-bottom: 20rpx;
.record-no {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.record-type {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.record-amount {
font-size: 32rpx;
font-weight: 700;
}
}
.record-info {
.info-row {
padding: 10rpx 0;
.info-label {
font-size: 24rpx;
color: var(--text-tertiary);
}
.info-value {
font-size: 24rpx;
color: var(--text-primary);
max-width: 380rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.record-actions {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid var(--border-light);
display: flex;
justify-content: flex-end;
.btn-pay {
background: var(--primary);
color: #fff;
border: none;
border-radius: 8rpx;
padding: 12rpx 32rpx;
font-size: 26rpx;
font-weight: 500;
width:100%;
&[disabled] {
opacity: 0.7;
}
&::after {
border: none;
}
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
.empty-icon {
font-size: 100rpx;
margin-bottom: 24rpx;
opacity: 0.5;
}
.empty-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-secondary);
}
}
.load-more {
text-align: center;
padding: 32rpx;
color: var(--text-tertiary);
font-size: 24rpx;
}
.recharge-popup {
width: 600rpx;
max-width: 90vw;
background: var(--bg-primary);
border-radius: var(--radius-large);
overflow: hidden;
box-sizing: border-box;
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid var(--border-light);
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
}
.popup-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
color: var(--text-tertiary);
cursor: pointer;
}
}
.recharge-amounts {
padding: 30rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
.amount-item {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid var(--border-light);
border-radius: var(--radius-medium);
cursor: pointer;
transition: all 0.3s;
&.active {
border-color: var(--primary);
background: rgba(0, 122, 255, 0.05);
.amount-value {
color: var(--primary);
font-weight: 600;
}
}
.amount-value {
font-size: 28rpx;
color: var(--text-primary);
}
}
}
.custom-input {
padding: 0 30rpx 30rpx;
.input-label {
font-size: 26rpx;
color: var(--text-secondary);
margin-bottom: 16rpx;
}
}
.popup-footer {
display: flex;
gap: 20rpx;
padding: 30rpx;
border-top: 1rpx solid var(--border-light);
.btn-apple {
flex: 1;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-medium);
font-size: 28rpx;
font-weight: 500;
border: none;
&[disabled] {
opacity: 0.7;
}
&.btn-secondary {
background: var(--gray-200);
color: var(--text-primary);
}
&.btn-primary {
background: var(--primary);
color: #fff;
}
}
}
}
</style>