chuangte_bike_newxcx/page_shanghu/guanli/area_ability_buy.vue

644 lines
16 KiB
Vue
Raw Normal View History

2026-05-29 17:26:45 +08:00
<template>
<view class="page">
<u-navbar title="购买套餐" :border-bottom="false" :background="bgc" back-icon-color="#111827" title-color="#111827" title-size="34" height="44"></u-navbar>
<scroll-view scroll-y class="page-scroll">
<view class="ability-card">
<view class="ability-card-header">
<view class="ability-card-top">
<image class="ability-icon" :src="abilityIcon" mode="aspectFill" v-if="abilityIcon"></image>
<view class="ability-icon ability-icon--placeholder" v-else>
<text>{{ (abilityName || '能').slice(0, 1) }}</text>
</view>
<view class="ability-info">
<text class="ability-name">{{ abilityName || '--' }}</text>
<text class="ability-area" v-if="areaName">{{ areaName }}</text>
</view>
</view>
<view hover-class="app-tap-hover" class="order-record-link" @tap="goOrderList">
<u-icon name="list" size="28" color="#4297F3"></u-icon>
<text>购买记录</text>
</view>
</view>
<view class="ability-card-stats">
<view class="stat-box">
<text class="stat-num">{{ formatCount(remainingCount) }}</text>
<text class="stat-label">剩余/</text>
</view>
<view class="stat-box">
<text class="stat-num">{{ formatCount(usedCount) }}</text>
<text class="stat-label">已用/</text>
</view>
</view>
</view>
<view class="section">
<view class="section-title">
<view class="section-bar"></view>
<text>选择套餐</text>
</view>
<view class="suit-loading" v-if="suitLoading">加载中...</view>
<view class="suit-empty" v-else-if="!suitList.length">暂无可用套餐</view>
<view class="suit-grid" v-else>
<view
hover-class="app-tap-hover"
class="suit-card"
v-for="(item, index) in suitList"
:key="item.id || index"
:class="{ 'suit-card--active': suitIndex === index }"
@click="selectSuit(index)"
>
<view class="suit-check" v-if="suitIndex === index">
<text class="suit-check-mark"></text>
</view>
<text class="suit-name">{{ suitName(item) }}</text>
<view class="suit-price">
<text class="suit-price-symbol">¥</text>
<text class="suit-price-num">{{ formatPrice(item) }}</text>
</view>
<text class="suit-count">{{ formatCount(suitTimes(item)) }} </text>
</view>
</view>
</view>
<view class="section">
<view class="section-title">
<view class="section-bar"></view>
<text>支付方式</text>
</view>
<view class="pay-loading" v-if="channelLoading">加载支付方式...</view>
<view class="pay-empty" v-else-if="!paymentList.length">暂无可用支付方式</view>
<radio-group v-else class="pay-list" @change="onChannelChange">
<label
class="pay-item"
v-for="item in paymentList"
:key="item.id"
>
<view class="pay-item-left">
<image class="pay-icon" :src="item.picture" mode="aspectFit" v-if="item.picture"></image>
<view class="pay-icon pay-icon--placeholder" v-else>
<text></text>
</view>
<text class="pay-name">{{ item.name || '支付' }}</text>
</view>
<radio
:value="String(item.id)"
:checked="String(channelId) === String(item.id)"
color="#4297F3"
/>
</label>
</radio-group>
</view>
</scroll-view>
<view class="footer-placeholder"></view>
<view class="footer-bar">
<view class="footer-price">
<text class="footer-label">应付</text>
<text class="footer-amount">¥{{ selectedPrice }}</text>
</view>
<view
hover-class="app-tap-hover"
class="footer-btn"
:class="{ 'footer-btn--disabled': !canPay }"
@click="handlePay"
>
<text>立即支付</text>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
bgc: { backgroundColor: '#fff' },
areaId: '',
areaName: '',
areaAbilityId: '',
abilityName: '',
abilityIcon: '',
remainingCount: 0,
usedCount: 0,
suitLoading: false,
suitList: [],
suitIndex: 0,
channelLoading: false,
paymentList: [],
channelId: '',
paying: false,
abilityId:''
}
},
computed: {
selectedSuit() {
return this.suitList[this.suitIndex] || null
},
selectedPrice() {
return this.formatPrice(this.selectedSuit || {})
},
canPay() {
return !!(this.selectedSuit && this.selectedSuit.id && this.channelId && !this.paying)
}
},
onLoad(e) {
this.areaId = e.areaId || ''
this.abilityId = e.abilityId ? decodeURIComponent(e.abilityId) : ''
this.areaName = e.areaName ? decodeURIComponent(e.areaName) : ''
this.areaAbilityId = e.areaAbilityId || ''
this.abilityName = e.abilityName ? decodeURIComponent(e.abilityName) : ''
this.abilityIcon = e.abilityIcon ? decodeURIComponent(e.abilityIcon) : ''
this.remainingCount = e.remainingCount != null ? e.remainingCount : 0
this.usedCount = e.usedCount != null ? e.usedCount : 0
this.fetchSuitList()
this.fetchPaymentChannels()
},
methods: {
goOrderList() {
if (!this.abilityId) {
uni.showToast({ title: '缺少能力ID', icon: 'none' })
return
}
const q = [
`abilityId=${this.abilityId}`,
`abilityName=${encodeURIComponent(this.abilityName || '')}`
].join('&')
uni.navigateTo({
url: `/page_shanghu/guanli/area_ability_order_list?${q}`
})
},
formatCount(val) {
const n = Number(val)
return Number.isFinite(n) ? n : 0
},
formatPrice(item) {
const n = Number(item && (item.price != null ? item.price : item.amount))
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
},
suitName(item) {
return (item && (item.name || item.suitName)) || '标准套餐'
},
suitTimes(item) {
if (!item) return 0
if (item.suitCount != null) return item.suitCount
if (item.count != null) return item.count
if (item.totalCount != null) return item.totalCount
return item.times
},
normalizeRows(res) {
if (res.code != 200) return []
return res.rows != null ? res.rows : (Array.isArray(res.data) ? res.data : [])
},
fetchSuitList() {
if (!this.areaId) return
this.suitLoading = true
const params = {
operationAreaId: this.areaId,
areaId: this.areaId,
abilityId:this.abilityId
}
if (this.areaAbilityId) {
params.areaAbilityId = this.areaAbilityId
}
this.$u
.get('/bst/abilitySuit/list', params)
.then((res) => {
this.suitList = this.normalizeRows(res)
if (res.code != 200) {
uni.showToast({ title: res.msg || '套餐加载失败', icon: 'none' })
}
if (this.suitList.length && this.suitIndex >= this.suitList.length) {
this.suitIndex = 0
}
})
.catch(() => {
this.suitList = []
uni.showToast({ title: '套餐加载失败', icon: 'none' })
})
.finally(() => {
this.suitLoading = false
})
},
fetchPaymentChannels() {
if (!this.areaId) return
const appId = this.$store.state.appid || 1
this.channelLoading = true
this.$u
.get('/app/channel/list', {
appId,
areaId: this.areaId,
bstType: 3,
appType: 1
})
.then((res) => {
if (res.code == 200 && Array.isArray(res.data)) {
this.paymentList = res.data
if (res.data.length) {
this.channelId = String(res.data[0].id)
}
} else {
this.paymentList = []
uni.showToast({ title: res.msg || '支付方式加载失败', icon: 'none' })
}
})
.catch(() => {
this.paymentList = []
uni.showToast({ title: '支付方式加载失败', icon: 'none' })
})
.finally(() => {
this.channelLoading = false
})
},
onChannelChange(e) {
if (e && e.detail && e.detail.value != null) {
this.channelId = String(e.detail.value)
}
},
selectSuit(index) {
this.suitIndex = index
},
getPayChannelId() {
return this.channelId
},
goTradeRecord() {
uni.showToast({ title: '交易记录开发中', icon: 'none' })
},
handlePay() {
if (!this.canPay) {
if (!this.selectedSuit || this.selectedSuit.id == null) {
uni.showToast({ title: '请选择套餐', icon: 'none' })
} else if (!this.getPayChannelId()) {
uni.showToast({ title: '支付通道未准备好', icon: 'none' })
this.fetchPaymentChannels()
} else if (!this.areaAbilityId) {
uni.showToast({ title: '缺少拓展能力信息', icon: 'none' })
}
return
}
this.paying = true
uni.showLoading({ title: '提交中...', mask: true })
const channelId = this.getPayChannelId()
const data = {
areaAbilityId: Number(this.areaAbilityId) || this.areaAbilityId,
suit: { ...this.selectedSuit },
channelId: Number(channelId) || channelId
}
this.$u
.post('/bst/abilityOrder', data)
.then((res) => {
if (res.code != 200) {
uni.showToast({ title: res.msg || '下单失败', icon: 'none' })
return
}
if (res.data && res.data.needPay === true && res.data.payParams) {
this.requestWxPay(res)
} else {
this.onPaySuccess()
}
})
.catch(() => {
uni.showToast({ title: '下单失败', icon: 'none' })
})
.finally(() => {
this.paying = false
uni.hideLoading()
})
},
requestWxPay(res) {
const that = this
uni.requestPayment({
provider: 'wxpay',
timeStamp: res.data.payParams.timeStamp,
nonceStr: res.data.payParams.nonceStr,
package: res.data.payParams.packageVal,
signType: res.data.payParams.signType,
paySign: res.data.payParams.paySign,
success() {
if (res.data.pay && res.data.pay.no) {
that.$u.put(`/app/pay/refreshPayResult?no=${res.data.pay.no}`).finally(() => {
that.onPaySuccess()
})
} else {
that.onPaySuccess()
}
},
fail() {
uni.showToast({ title: '已取消支付', icon: 'none' })
}
})
},
onPaySuccess() {
uni.showToast({ title: '购买成功', icon: 'success' })
const pages = getCurrentPages()
const prev = pages[pages.length - 2]
if (prev && prev.$vm) {
prev.$vm._needRefresh = true
}
setTimeout(() => uni.navigateBack(), 600)
}
}
}
</script>
<style lang="scss">
page {
background: #f6f8fa;
}
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.page-scroll {
flex: 1;
height: calc(100vh - 44px - 120rpx);
padding: 24rpx;
box-sizing: border-box;
}
.ability-card {
background: #fff;
border-radius: 24rpx;
padding: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(44, 138, 240, 0.06);
}
.ability-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.ability-card-top {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-start;
}
.ability-icon {
width: 88rpx;
height: 88rpx;
border-radius: 16rpx;
flex-shrink: 0;
background: #f3f4f6;
}
.ability-icon--placeholder {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #86efac 0%, #22c55e 100%);
text {
font-size: 38rpx;
font-weight: 700;
color: #fff;
}
}
.ability-info {
flex: 1;
min-width: 0;
margin: 0 16rpx;
}
.ability-name {
display: block;
font-size: 34rpx;
font-weight: 600;
color: #111827;
}
.ability-area {
display: block;
margin-top: 8rpx;
font-size: 26rpx;
color: #6b7280;
}
.order-record-link {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
font-size: 22rpx;
color: #4297F3;
padding-top: 4rpx;
text {
margin-top: 4rpx;
}
}
.ability-card-stats {
margin-top: 20rpx;
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
.stat-box {
min-width: 120rpx;
padding: 12rpx 20rpx;
background: #f8fafc;
border-radius: 12rpx;
text-align: center;
border: 1rpx solid #eef2f7;
}
.stat-num {
display: block;
font-size: 32rpx;
font-weight: 700;
color: #111827;
line-height: 1.2;
}
.stat-label {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: #9ca3af;
}
.section {
margin-top: 28rpx;
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 20rpx;
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
.section-bar {
width: 8rpx;
height: 32rpx;
background: #4297F3;
border-radius: 4rpx;
margin-right: 12rpx;
}
.suit-loading,
.suit-empty {
padding: 60rpx 0;
text-align: center;
font-size: 28rpx;
color: #9ca3af;
background: #fff;
border-radius: 16rpx;
}
.suit-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.suit-card {
position: relative;
width: calc(50% - 10rpx);
box-sizing: border-box;
background: #fff;
border: 2rpx solid #e5e7eb;
border-radius: 20rpx;
padding: 28rpx 20rpx 24rpx;
overflow: hidden;
}
.suit-card--active {
border-color: #4297F3;
border-width: 3rpx;
background: linear-gradient(135deg, #f0f7ff 0%, #fff 100%);
box-shadow: 0 6rpx 20rpx rgba(66, 151, 243, 0.2);
}
.suit-check {
position: absolute;
right: 0;
bottom: 0;
width: 52rpx;
height: 52rpx;
background: linear-gradient(135deg, #4297F3 0%, #60a5fa 100%);
clip-path: polygon(100% 0, 100% 100%, 0 100%);
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: 0 6rpx 6rpx 0;
}
.suit-check-mark {
font-size: 24rpx;
color: #fff;
font-weight: 700;
}
.suit-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #111827;
text-align: center;
}
.suit-price {
margin-top: 16rpx;
display: flex;
align-items: baseline;
justify-content: center;
color: #f17f37;
}
.suit-price-symbol {
font-size: 28rpx;
font-weight: 600;
}
.suit-price-num {
font-size: 48rpx;
font-weight: 700;
line-height: 1;
}
.suit-count {
display: block;
margin-top: 12rpx;
text-align: center;
font-size: 26rpx;
color: #6b7280;
}
.pay-loading,
.pay-empty {
padding: 40rpx 0;
text-align: center;
font-size: 28rpx;
color: #9ca3af;
background: #fff;
border-radius: 16rpx;
}
.pay-list {
display: block;
}
.pay-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 16rpx;
padding: 24rpx 28rpx;
margin-bottom: 16rpx;
border: 1rpx solid #eef2f7;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
}
.pay-item-left {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.pay-icon {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
flex-shrink: 0;
background: #f3f4f6;
}
.pay-icon--placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #07c160;
text {
font-size: 28rpx;
font-weight: 700;
color: #fff;
}
}
.pay-name {
margin-left: 20rpx;
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.footer-placeholder {
height: 140rpx;
}
.footer-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
z-index: 100;
}
.footer-price {
flex: 1;
min-width: 0;
}
.footer-label {
font-size: 24rpx;
color: #9ca3af;
margin-right: 8rpx;
}
.footer-amount {
font-size: 40rpx;
font-weight: 700;
color: #f17f37;
}
.footer-btn {
width: 280rpx;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: linear-gradient(135deg, #4297F3 0%, #2b76e5 100%);
border-radius: 44rpx;
color: #fff;
font-size: 30rpx;
font-weight: 600;
}
.footer-btn--disabled {
opacity: 0.5;
}
</style>