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

846 lines
25 KiB
Vue
Raw Normal View History

2026-03-24 08:01:24 +00:00
<template>
<view class="payment">
<u-navbar
title="缴费"
:border-bottom="false"
back-icon-color="#333"
title-color="#333"
:background="{ background: '#FFFFFF' }"
/>
<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, prepay, getPayResult, alipayJspay, wechatJspay, unifiedTradeQuery } from '@/api/pay'
import { useUserStore } from '@/stores/user'
import config from '@/config'
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 orderId = ref('')
// 计算属性
const canSubmit = computed(() => {
const numAmount = parseFloat(amount.value)
return numAmount > 0 && selectedPayWay.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: '支付中...' })
const params: any = {
pay_way: selectedPayWay.value,
amount: parseFloat(amount.value),
remark: remark.value
}
if (selectedPayWay.value === 'balance') {
params.password = password.value
}
const res = await prepay(params)
if (res && res.order_id) {
orderId.value = res.order_id
// 微信支付 - 开放银行微信支付
if (selectedPayWay.value === 'wechat' && res.pay_params) {
await wechatJspayPay(res.pay_params)
}
// 支付宝支付 - 开放银行服务窗支付
else if (selectedPayWay.value === 'alipay' && res.pay_params) {
await alipayJspayPay(res.pay_params)
}
// 余额支付直接成功
else {
uni.hideLoading()
goToResult(true)
}
} 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 (payParams: any) => {
try {
// 构建开放银行支付请求参数
const params = {
version: '3.0',
charset: 'UTF-8',
service: 'pay.alipay.jspay',
mch_id: paymentConfig.mch_id, // 商户号
out_trade_no: orderId.value,
total_fee: String(Math.round(parseFloat(amount.value) * 100)), // 金额转换为分
body: remark.value || '缴费支付',
mch_create_ip: '127.0.0.1', // 需要获取真实IP
notify_url: paymentConfig.alipay.notify_url,
buyer_logon_id: '', // 买家支付宝账号(可选)
'terminal_info.terminal_type': '11', // 终端类型
'terminal_info.terminal_id': paymentConfig.terminal_id,
'terminal_info.app_version': '1.000000'
}
const res = await alipayJspay(params)
// 检查返回结果
if (res && res.status === '0' && res.result_code === '0') {
// 解析 pay_info
const payInfo = JSON.parse(res.pay_info || '{}')
if (payInfo.tradeNO) {
// #ifdef APP-PLUS
// APP 环境使用 tradeNO 唤起支付宝
uni.requestPayment({
provider: 'alipay',
orderInfo: {
tradeNO: payInfo.tradeNO
},
success: () => {
checkPayResult()
},
fail: (err: any) => {
console.error('支付宝支付失败:', err)
goToResult(false)
}
})
// #endif
// #ifdef H5
// H5 支付宝服务窗支付
// 方式1使用 pay_url 跳转
if (res.pay_url) {
window.location.href = res.pay_url
return
}
// 方式2使用 tradeNO 唤起支付宝
if (payInfo.tradeNO && 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.err_msg || res.message || '支付请求失败')
}
} catch (error: any) {
console.error('开放银行支付错误:', error)
uni.showToast({ title: error.message || '支付请求失败', icon: 'none' })
goToResult(false)
}
}
// 开放银行微信支付JSPay
const wechatJspayPay = async (payParams: any) => {
try {
// 构建开放银行微信支付请求参数
const params: any = {
version: '3.0',
charset: 'UTF-8',
service: 'pay.weixin.jspay',
mch_id: paymentConfig.mch_id, // 商户号
out_trade_no: orderId.value,
total_fee: String(Math.round(parseFloat(amount.value) * 100)), // 金额转换为分
body: remark.value || '缴费支付',
mch_create_ip: '127.0.0.1', // 需要获取真实IP
notify_url: paymentConfig.wechat.notify_url,
'terminal_info.terminal_type': '11', // 终端类型
'terminal_info.terminal_id': paymentConfig.terminal_id,
'terminal_info.app_version': '1.000000'
}
// 公众号/小程序支付需要 sub_appid 和 sub_openid
// #ifdef MP-WEIXIN
params.is_minipg = '1' // 小程序支付标识
params.sub_appid = paymentConfig.wechat.sub_appid // 小程序 appid
// 获取用户 openid
const openid = userStore.userInfo?.openid || ''
if (openid) {
params.sub_openid = openid
}
// #endif
// #ifdef H5
// H5 公众号支付 - 用户在微信内打开网页
params.is_raw = '1' // 原生态 JS 支付
params.sub_appid = paymentConfig.wechat.sub_appid // 公众号 appid
// 获取用户 openid如果在微信内
const openid = userStore.userInfo?.openid || ''
if (openid) {
params.sub_openid = openid
}
// #endif
const res = await wechatJspay(params)
// 检查返回结果
if (res && res.status === '0' && res.result_code === '0') {
const payInfo = res.pay_info
if (payInfo) {
// #ifdef MP-WEIXIN
// 小程序支付 - 解析 XML 格式的 pay_info
const payParams = parseXmlPayInfo(payInfo)
if (payParams) {
uni.requestPayment({
provider: 'wxpay',
...payParams,
success: () => {
checkPayResult()
},
fail: (err: any) => {
console.error('微信支付失败:', err)
goToResult(false)
}
})
} else {
throw new Error('解析支付参数失败')
}
// #endif
// #ifdef H5
// H5 公众号支付 - 解析 XML 格式的 pay_info
const payParams = parseXmlPayInfo(payInfo)
if (payParams && typeof WeixinJSBridge !== 'undefined') {
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
payParams,
(res: any) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
checkPayResult()
} else {
goToResult(false)
}
}
)
} else if (payParams) {
// 使用 uni.requestPayment
uni.requestPayment({
provider: 'wxpay',
...payParams,
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.err_msg || 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: '查询中...' })
// 构建订单查询参数
const params = {
version: '3.0',
service: 'unified.trade.query',
mch_id: paymentConfig.mch_id, // 商户号
out_trade_no: orderId.value
}
const res = await unifiedTradeQuery(params)
uni.hideLoading()
// 检查查询结果
if (res && res.status === '0' && res.result_code === '0') {
// trade_state: SUCCESS-支付成功, REFUND-转入退款, NOTPAY-未支付,
// CLOSED-已关闭, REVOKED-已撤销, USERPAYING-用户支付中, PAYERROR-支付失败
const tradeState = res.trade_state
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 {
const res = await getPayResult({ order_id: orderId.value })
if (res && res.pay_status === 1) {
goToResult(true)
} else {
goToResult(false)
}
} catch (error) {
goToResult(false)
}
}
// 跳转到结果页
const goToResult = (success: boolean) => {
uni.redirectTo({
url: `/pages/payment_result/payment_result?status=${success ? 'success' : 'fail'}&order_id=${orderId.value}`
})
}
onMounted(() => {
getPayWayData()
})
</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>