edu/uniapp/src/pages/payment/payment.vue

787 lines
23 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="payment">
<view class="payment-content">
<!-- 缴费金额输入 -->
<view class="amount-section">
<view class="section-title">缴费金额</view>
<view class="amount-input-wrapper">
<text class="currency">¥</text>
<input
class="amount-input"
type="digit"
v-model="amount"
placeholder="请输入缴费金额"
placeholder-class="placeholder"
/>
</view>
</view>
<!-- 缴费说明 -->
<view class="remark-section">
<view class="section-title">缴费说明(选填)</view>
<textarea
class="remark-input"
v-model="remark"
placeholder="请输入缴费说明"
placeholder-class="placeholder"
maxlength="100"
/>
<text class="remark-count">{{ remark.length }}/100</text>
</view>
<!-- 支付方式 -->
<view class="payway-section">
<view class="section-title">支付方式</view>
<view class="payway-list">
<view
class="payway-item"
v-for="item in payWayList"
:key="item.pay_way"
:class="{ active: selectedPayWay === item.pay_way }"
@click="selectPayWay(item.pay_way)"
>
<view class="payway-info">
<u-icon
:name="getPayWayIcon(item.pay_way)"
size="48"
:color="getPayWayColor(item.pay_way)"
/>
<text class="payway-name">{{ item.name }}</text>
</view>
<view class="payway-check">
<u-icon
v-if="selectedPayWay === item.pay_way"
name="checkmark-circle-fill"
size="40"
color="#3B82F6"
/>
<view v-else class="check-circle"></view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部提交按钮 -->
<view class="footer">
<view class="total-amount">
<text class="label">合计:</text>
<text class="currency">¥</text>
<text class="amount">{{ amount || '0.00' }}</text>
</view>
<view class="submit-btn" :class="{ disabled: !canSubmit }" @click="handleSubmit">
立即缴费
</view>
</view>
<!-- 支付密码弹窗 -->
<u-popup v-model="showPasswordPopup" mode="center" border-radius="20">
<view class="password-popup">
<view class="popup-title">请输入支付密码</view>
<u-message-input
mode="box"
:maxlength="6"
:focus="true"
v-model="password"
@finish="onPasswordFinish"
/>
<view class="popup-cancel" @click="showPasswordPopup = false"> 取消</view>
</view>
</u-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getPayWay, getPayResult, alipayJspay, wechatJspay, bankFeeTradeQuery } from '@/api/pay'
import { useUserStore } from '@/stores/user'
import config from '@/config'
import { onLoad } from '@dcloudio/uni-app'
declare const WeixinJSBridge: any
declare const AlipayJSBridge: any
const userStore = useUserStore()
// 支付配置
const paymentConfig = config.payment
// 数据
const amount = ref('')
const remark = ref('')
const payWayList = ref<any[]>([])
const selectedPayWay = ref('')
const showPasswordPopup = ref(false)
const password = ref('')
const studentId = ref<number | null>(null)
const outTradeNo = ref('')
// 计算属性
const canSubmit = computed(() => {
const numAmount = parseFloat(amount.value)
return numAmount > 0 && selectedPayWay.value && !!studentId.value
})
// 获取支付方式 - 使用内置的开放银行支付方式
const getPayWayData = async () => {
// 直接设置开放银行支持的支付方式
payWayList.value = [
{
pay_way: 'wechat',
name: '微信支付'
},
{
pay_way: 'alipay',
name: '支付宝支付'
}
]
selectedPayWay.value = 'wechat'
}
// 选择支付方式
const selectPayWay = (payWay: string) => {
selectedPayWay.value = payWay
}
// 获取支付方式图标
const getPayWayIcon = (payWay: string) => {
const iconMap: Record<string, string> = {
wechat: 'weixin-fill',
alipay: 'zhifubao-circle-fill',
balance: 'rmb-circle-fill'
}
return iconMap[payWay] || 'rmb-circle-fill'
}
// 获取支付方式颜色
const getPayWayColor = (payWay: string) => {
const colorMap: Record<string, string> = {
wechat: '#07C160',
alipay: '#1677FF',
balance: '#FF6B00'
}
return colorMap[payWay] || '#999'
}
// 提交缴费
const handleSubmit = () => {
if (!canSubmit.value) return
const numAmount = parseFloat(amount.value)
if (numAmount <= 0) {
uni.showToast({ title: '请输入有效的缴费金额', icon: 'none' })
return
}
// 余额支付需要输入密码
if (selectedPayWay.value === 'balance') {
showPasswordPopup.value = true
return
}
doPay()
}
// 密码输入完成
const onPasswordFinish = () => {
showPasswordPopup.value = false
doPay()
}
// 执行支付
const doPay = async () => {
try {
uni.showLoading({ title: '支付中...' })
if (!studentId.value) {
throw new Error('缺少学生ID请从预报名详情进入缴费')
}
// 只走开放银行正式接口:由后端生成 out_trade_noQDKXJG-YBMJF-序号)
if (selectedPayWay.value === 'wechat') {
await wechatJspayPay()
} else if (selectedPayWay.value === 'alipay') {
await alipayJspayPay()
} else {
throw new Error('暂不支持该支付方式')
}
} catch (error: any) {
uni.hideLoading()
uni.showToast({ title: error.message || '支付失败', icon: 'none' })
password.value = ''
}
}
// 微信支付
const wechatPay = (payParams: any) => {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.requestPayment({
provider: 'wxpay',
...payParams,
success: () => {
resolve(true)
checkPayResult()
},
fail: (err: any) => {
reject(err)
goToResult(false)
}
})
// #endif
// #ifdef H5
if (payParams.mweb_url) {
window.location.href = payParams.mweb_url
} else if (payParams.jsapi_params) {
// JSAPI支付
const {
appId,
timeStamp,
nonceStr,
package: packageStr,
signType,
paySign
} = payParams.jsapi_params
if (typeof WeixinJSBridge !== 'undefined') {
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
{ appId, timeStamp, nonceStr, package: packageStr, signType, paySign },
(res: any) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
resolve(true)
checkPayResult()
} else {
reject(res)
goToResult(false)
}
}
)
}
}
// #endif
// #ifdef APP-PLUS
uni.requestPayment({
provider: 'wxpay',
orderInfo: payParams,
success: () => {
resolve(true)
checkPayResult()
},
fail: (err: any) => {
reject(err)
goToResult(false)
}
})
// #endif
})
}
// 支付宝支付
const alipay = (payParams: any) => {
return new Promise((resolve, reject) => {
// #ifdef H5
if (payParams.form) {
const div = document.createElement('div')
div.innerHTML = payParams.form
document.body.appendChild(div)
div.querySelector('form')?.submit()
}
// #endif
// #ifdef APP-PLUS
uni.requestPayment({
provider: 'alipay',
orderInfo: payParams.order_str,
success: () => {
resolve(true)
checkPayResult()
},
fail: (err: any) => {
reject(err)
goToResult(false)
}
})
// #endif
})
}
// 开放银行支付宝服务窗支付
const alipayJspayPay = async () => {
try {
const res = await alipayJspay({
amountFen: Math.round(parseFloat(String(amount.value)) * 100),
body: remark.value || '缴费支付',
studentId: Number(studentId.value),
buyerLogonId: '',
includeRawResponse: false
})
// 检查返回结果(后端 AjaxResult.data 为驼峰字段)
if (res && res.status === '0' && res.resultCode === '0') {
outTradeNo.value = res.outTradeNo || ''
// #ifdef H5
if (res.payUrl) {
window.location.href = res.payUrl
return
}
// #endif
const payInfo = JSON.parse(res.payInfo || '{}')
if (payInfo.tradeNO) {
// #ifdef APP-PLUS
uni.requestPayment({
provider: 'alipay',
orderInfo: {
tradeNO: payInfo.tradeNO
},
success: () => {
checkPayResult()
},
fail: (err: any) => {
console.error('支付宝支付失败:', err)
goToResult(false)
}
})
// #endif
// #ifdef H5
if (typeof AlipayJSBridge !== 'undefined') {
AlipayJSBridge.call(
'tradePay',
{
tradeNO: payInfo.tradeNO
},
(result: any) => {
if (result.resultCode === '9000') {
checkPayResult()
} else {
goToResult(false)
}
}
)
}
// #endif
} else {
throw new Error('获取支付参数失败')
}
} else {
throw new Error(res.errMsg || res.message || '支付请求失败')
}
} catch (error: any) {
console.error('开放银行支付错误:', error)
uni.showToast({ title: error.message || '支付请求失败', icon: 'none' })
goToResult(false)
}
}
// 开放银行微信支付JSPay
const wechatJspayPay = async () => {
try {
// 走后端正式接口openid / termId / mchId / notify 均由服务端处理
const res = await wechatJspay({
amountFen: Math.round(parseFloat(String(amount.value)) * 100),
body: remark.value || '缴费支付',
studentId: Number(studentId.value),
includeRawResponse: false
})
// 检查返回结果
if (res && res.status === '0' && res.resultCode === '0') {
outTradeNo.value = res.outTradeNo || ''
const payInfo = res.payInfo
if (payInfo) {
// #ifdef MP-WEIXIN
// 小程序支付 - 解析 XML 格式的 pay_info
const miniPayParams = parseXmlPayInfo(payInfo)
if (miniPayParams) {
;(uni as any).requestPayment({
provider: 'wxpay',
...miniPayParams,
success: () => {
checkPayResult()
},
fail: (err: any) => {
console.error('微信支付失败:', err)
goToResult(false)
}
})
} else {
throw new Error('解析支付参数失败')
}
// #endif
// #ifdef H5
// H5 公众号支付 - 解析 XML 格式的 pay_info
const h5PayParams = parseXmlPayInfo(payInfo)
if (h5PayParams && typeof WeixinJSBridge !== 'undefined') {
WeixinJSBridge.invoke('getBrandWCPayRequest', h5PayParams, (res: any) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
checkPayResult()
} else {
goToResult(false)
}
})
} else if (h5PayParams) {
// 使用 uni.requestPayment
;(uni as any).requestPayment({
provider: 'wxpay',
...h5PayParams,
success: () => {
checkPayResult()
},
fail: (err: any) => {
console.error('微信支付失败:', err)
goToResult(false)
}
})
} else {
throw new Error('解析支付参数失败')
}
// #endif
// #ifdef APP-PLUS
// APP 环境
uni.requestPayment({
provider: 'wxpay',
orderInfo: payInfo,
success: () => {
checkPayResult()
},
fail: (err: any) => {
console.error('微信支付失败:', err)
goToResult(false)
}
})
// #endif
} else {
throw new Error('获取支付参数失败')
}
} else {
throw new Error(res.errMsg || res.message || '支付请求失败')
}
} catch (error: any) {
console.error('开放银行微信支付错误:', error)
uni.showToast({ title: error.message || '支付请求失败', icon: 'none' })
goToResult(false)
}
}
// 解析 XML 格式的支付参数
const parseXmlPayInfo = (xmlStr: string) => {
try {
// 简单的 XML 解析
const getValue = (xml: string, tag: string) => {
const regex = new RegExp(
`<${tag}><!\[CDATA\[(.*?)\]\]></${tag}>|<${tag}>(.*?)</${tag}>`,
'i'
)
const match = xml.match(regex)
return match ? match[1] || match[2] : ''
}
return {
appId: getValue(xmlStr, 'appId'),
timeStamp: getValue(xmlStr, 'timeStamp'),
nonceStr: getValue(xmlStr, 'nonceStr'),
package: getValue(xmlStr, 'package'),
signType: getValue(xmlStr, 'signType') || 'RSA',
paySign: getValue(xmlStr, 'paySign')
}
} catch (error) {
console.error('解析 XML 失败:', error)
return null
}
}
// 查询支付结果 - 使用开放银行订单查询接口
const checkPayResult = async () => {
try {
uni.showLoading({ title: '查询中...' })
if (!outTradeNo.value) {
throw new Error('缺少 outTradeNo')
}
const res = await bankFeeTradeQuery({ outTradeNo: outTradeNo.value })
uni.hideLoading()
// 检查查询结果
if (res && res.status === '0' && res.resultCode === '0') {
// trade_state: SUCCESS-支付成功, REFUND-转入退款, NOTPAY-未支付,
// CLOSED-已关闭, REVOKED-已撤销, USERPAYING-用户支付中, PAYERROR-支付失败
const tradeState = res.tradeState
if (tradeState === 'SUCCESS') {
// 支付成功
goToResult(true)
} else if (tradeState === 'NOTPAY' || tradeState === 'USERPAYING') {
// 未支付或支付中,可以轮询查询
uni.showToast({ title: '支付处理中,请稍后...', icon: 'none' })
// 3秒后再次查询
setTimeout(() => {
checkPayResult()
}, 3000)
} else {
// 其他状态视为失败
goToResult(false)
}
} else {
// 查询失败,使用备用查询方式
console.error('开放银行订单查询失败:', res)
await fallbackCheckPayResult()
}
} catch (error) {
uni.hideLoading()
console.error('查询支付结果错误:', error)
// 使用备用查询方式
await fallbackCheckPayResult()
}
}
// 备用查询方式 - 使用原系统接口
const fallbackCheckPayResult = async () => {
try {
// 兜底:仍然用开放银行查单
if (!outTradeNo.value) return goToResult(false)
const res = await bankFeeTradeQuery({ outTradeNo: outTradeNo.value })
if (res && res.status === '0' && res.resultCode === '0' && res.tradeState === 'SUCCESS')
return goToResult(true)
return goToResult(false)
} catch (error) {
goToResult(false)
}
}
// 跳转到结果页
const goToResult = (success: boolean) => {
uni.redirectTo({
url: `/pages/payment_result/payment_result?status=${success ? 'success' : 'fail'}&id=${
outTradeNo.value
}&from=bankFee`
})
}
onMounted(() => {
getPayWayData()
})
onLoad((options: any) => {
const sid = Number(options?.studentId || 0)
if (sid > 0) {
studentId.value = sid
}
})
</script>
<style lang="scss" scoped>
.payment {
min-height: 100vh;
background-color: #f5f7fa;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
.payment-content {
padding: 20rpx;
}
.section-title {
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
}
// 金额输入区域
.amount-section {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
.amount-input-wrapper {
display: flex;
align-items: center;
border-bottom: 2rpx solid #e5e7eb;
padding: 20rpx 0;
.currency {
font-size: 60rpx;
font-weight: 600;
color: #333;
margin-right: 20rpx;
}
.amount-input {
flex: 1;
font-size: 60rpx;
font-weight: 600;
color: #333;
height: 80rpx;
}
.placeholder {
font-size: 40rpx;
color: #999;
font-weight: normal;
}
}
}
// 备注区域
.remark-section {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
position: relative;
.remark-input {
width: 100%;
height: 160rpx;
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
.placeholder {
font-size: 28rpx;
color: #999;
}
.remark-count {
position: absolute;
right: 30rpx;
bottom: 20rpx;
font-size: 24rpx;
color: #999;
}
}
// 支付方式区域
.payway-section {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
.payway-list {
.payway-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 0;
border-bottom: 2rpx solid #f3f4f6;
&:last-child {
border-bottom: none;
}
&.active {
.payway-name {
color: #333;
font-weight: 500;
}
}
.payway-info {
display: flex;
align-items: center;
.payway-name {
font-size: 30rpx;
color: #666;
margin-left: 20rpx;
}
}
.payway-check {
.check-circle {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #d1d5db;
border-radius: 50%;
}
}
}
}
}
// 底部区域
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #ffffff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
.total-amount {
display: flex;
align-items: baseline;
.label {
font-size: 28rpx;
color: #666;
}
.currency {
font-size: 32rpx;
color: #ff6b00;
font-weight: 600;
}
.amount {
font-size: 48rpx;
color: #ff6b00;
font-weight: 600;
}
}
.submit-btn {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #ffffff;
font-size: 32rpx;
font-weight: 500;
padding: 24rpx 60rpx;
border-radius: 40rpx;
&:active {
opacity: 0.9;
}
&.disabled {
background: #d1d5db;
color: #9ca3af;
}
}
}
// 密码弹窗
.password-popup {
width: 600rpx;
padding: 40rpx;
background: #ffffff;
border-radius: 20rpx;
.popup-title {
font-size: 32rpx;
font-weight: 500;
color: #333;
text-align: center;
margin-bottom: 40rpx;
}
.popup-cancel {
font-size: 28rpx;
color: #999;
text-align: center;
margin-top: 40rpx;
padding: 20rpx;
}
}
</style>