1b4dbda28e
用户选择:PC 不再跳支付宝账单页,改为像微信一样直接出一张二维码。 此前 PC(收银台 + 购标流程)走电脑网站支付(自提交表单 + 跳转), H5 走扫码,两个渠道两种交互。 改动: - tender/pay.vue 与 template-07/BuyDocument.vue 删除自提交表单整套逻辑 (alipayForm 状态、openAlipayForm 的 document.write、弹窗拦截兜底、 模板里的「前往支付宝付款」分支)——两个渠道现在都是 codeUrl 出码, 渲染与轮询完全复用同一套。 - 支付宝提交 payType=alipay(裸值),由后端按默认渠道(当面付扫码)下单。 - 两处文件头注释同步:支付宝默认当面付,与微信 Native 同一交互。 不改的部分: - 后端本就两条路都实现(precreate / page.pay),本次只翻转默认, 网页支付保留为显式 alipay_page / alipay_web 的可选路径—— 扫码签约「当面付」、网页支付签约「电脑网站支付」,是不同的产品签约, 留一条备用便于按实际签约情况切换,不必改代码。 - H5 原本就是扫码(显式 alipay_qr),行为不变。 验证:eslint 0 error;npm run build:dev 通过。
326 lines
13 KiB
Vue
326 lines
13 KiB
Vue
<template>
|
||
<div class="container mx-auto px-4 py-10">
|
||
<NuxtLink to="/tender" class="text-sm text-blue-600 hover:underline">← 返回标书中心</NuxtLink>
|
||
|
||
<!-- 未登录提示 -->
|
||
<div v-if="!loggedIn" class="mx-auto mt-6 max-w-xl rounded-lg border border-gray-200 p-8 text-center">
|
||
<p class="text-lg text-gray-700">购买标书需要先登录企业账号</p>
|
||
<div class="mt-6 flex gap-4 justify-center">
|
||
<NuxtLink :to="`/login?redirect=${redirect}`" class="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">去登录</NuxtLink>
|
||
<NuxtLink :to="`/register?redirect=${redirect}`" class="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50">企业注册</NuxtLink>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 表单 -->
|
||
<div v-else-if="tender && step === 'form'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-6">
|
||
<h1 class="text-xl font-bold text-gray-900">购买标书</h1>
|
||
<div class="mt-4 rounded-md bg-gray-50 p-4 text-sm">
|
||
<p class="font-semibold text-gray-800">{{ tender.projectName }}</p>
|
||
<p class="mt-1 text-gray-500">项目编号:{{ tender.projectNo }}</p>
|
||
<p class="mt-2 text-blue-600">
|
||
信息服务费:<span class="text-2xl font-bold">¥{{ formatMoney(tender.tenderPrice) }}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<form class="mt-6 space-y-4" @submit.prevent="submit">
|
||
<div>
|
||
<label class="text-sm text-gray-600">购买联系人</label>
|
||
<input v-model="form.contactName" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="姓名" />
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-gray-600">联系电话</label>
|
||
<input v-model="form.contactPhone" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="手机号" />
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-gray-600">联系邮箱</label>
|
||
<input v-model="form.contactEmail" type="email" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" placeholder="邮箱" />
|
||
</div>
|
||
<div>
|
||
<label class="text-sm text-gray-600">购买数量</label>
|
||
<input v-model.number="form.quantity" type="number" min="1" class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-sm" />
|
||
</div>
|
||
|
||
<button type="submit" class="w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:opacity-50" :disabled="submitting">
|
||
{{ submitting ? '提交中...' : '提交订单并支付' }}
|
||
</button>
|
||
<p v-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- 支付 -->
|
||
<div v-else-if="step === 'pay'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
|
||
<h1 class="text-xl font-bold text-gray-900">请使用{{ channelLabel }}扫码支付</h1>
|
||
<p class="mt-2 text-sm text-gray-500">订单号:{{ orderNo }}</p>
|
||
<p class="my-4 text-2xl font-bold text-blue-600">¥{{ formatMoney(totalAmount) }}</p>
|
||
|
||
<!--
|
||
支付方式选择。**切换方式即重新发起支付**——同一订单号不能换支付类型重复下单
|
||
(渠道侧会拒),所以先清掉上一张码,避免两张码同时留在页面上。
|
||
-->
|
||
<div class="mb-6 inline-flex rounded-md border border-gray-200 p-1">
|
||
<button
|
||
v-for="opt in payOptions"
|
||
:key="opt.value"
|
||
type="button"
|
||
class="rounded px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50"
|
||
:class="payType === opt.value
|
||
? 'bg-blue-600 text-white'
|
||
: 'text-gray-600 hover:bg-gray-50'"
|
||
:disabled="refreshing"
|
||
@click="switchPayType(opt.value)"
|
||
>
|
||
{{ opt.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<!--
|
||
两个渠道都是出码:微信 Native 与支付宝当面付(默认渠道)返回同一形态的 codeUrl,
|
||
渲染与轮询完全复用。支付宝不跳转账单——当面付需要在开放平台签约「当面付」,
|
||
与「电脑网站支付」是不同的产品签约(见 ADR 0010)。
|
||
-->
|
||
<template v-if="codeUrl">
|
||
<!-- 过期只是置灰提示,仍留在页面上:万一用户是刚过点就扫的,还能看见自己扫的是哪张码 -->
|
||
<div :class="payExpired ? 'opacity-30' : ''">
|
||
<HjcPayQrcode :value="codeUrl" :size="240" :alt="qrAlt" />
|
||
</div>
|
||
<p class="mt-3 text-sm text-gray-500">请在 2 小时内用{{ channelLabel }}扫描上方二维码完成支付</p>
|
||
<p v-if="payExpired" class="mt-2 text-sm text-amber-600">二维码已过期,请重新获取后再扫码</p>
|
||
</template>
|
||
<div v-else class="rounded-md bg-gray-50 px-4 py-6 text-sm text-gray-500">
|
||
暂未获取到支付信息,请点击下方按钮重新获取
|
||
</div>
|
||
|
||
<button
|
||
v-if="codeUrl"
|
||
class="mt-6 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
|
||
:disabled="confirming"
|
||
@click="confirmPaid"
|
||
>
|
||
{{ confirming ? '确认中...' : '我已完成支付,确认订单' }}
|
||
</button>
|
||
<button
|
||
class="mt-6 w-full rounded-md py-3 font-medium disabled:opacity-50"
|
||
:class="codeUrl
|
||
? 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||
: 'bg-blue-600 text-white hover:bg-blue-700'"
|
||
:disabled="refreshing"
|
||
@click="refreshQr"
|
||
>
|
||
{{ refreshing ? '获取中...' : (codeUrl ? '重新获取二维码' : '获取支付信息') }}
|
||
</button>
|
||
|
||
<p v-if="error || payError" class="mt-3 text-sm text-red-500">{{ error || payError }}</p>
|
||
<p v-else-if="querying" class="mt-3 text-sm text-gray-400">正在确认支付状态...</p>
|
||
</div>
|
||
|
||
<!-- 完成 -->
|
||
<div v-else-if="step === 'done'" class="mx-auto mt-6 max-w-2xl rounded-lg border border-green-200 bg-green-50 p-8 text-center">
|
||
<p class="text-xl font-bold text-green-700">购买成功</p>
|
||
<p class="mt-2 text-sm text-green-600">订单号:{{ orderNo }},标书已可查看/下载</p>
|
||
<NuxtLink to="/tender" class="mt-6 inline-block rounded-md bg-green-600 px-6 py-2 text-white hover:bg-green-700">返回标书中心</NuxtLink>
|
||
</div>
|
||
|
||
<div v-else-if="notFound" class="py-20 text-center text-gray-500">项目不存在</div>
|
||
<SiteLoading v-else />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { Tender } from '~/types/tender'
|
||
|
||
const route = useRoute()
|
||
const id = route.query.id as string
|
||
const { isLoggedIn, handleAuthCode } = useHjcAuth()
|
||
|
||
const loggedIn = ref(false)
|
||
const tender = shallowRef<Tender | null>(null)
|
||
const notFound = ref(false)
|
||
const submitting = ref(false)
|
||
const refreshing = ref(false)
|
||
const confirming = ref(false)
|
||
const error = ref('')
|
||
const step = ref<'form' | 'pay' | 'done'>('form')
|
||
const orderNo = ref('')
|
||
const codeUrl = ref('')
|
||
const totalAmount = ref(0)
|
||
|
||
/** 当前支付方式。默认微信,与接入支付宝之前的行为一致 */
|
||
const payType = ref<'wechat' | 'alipay'>('wechat')
|
||
const payOptions = [
|
||
{ value: 'wechat' as const, label: '微信支付' },
|
||
{ value: 'alipay' as const, label: '支付宝' }
|
||
]
|
||
|
||
/** 提示文案里的渠道名(只影响措辞,不影响判定) */
|
||
const channelLabel = computed(() => (payType.value === 'alipay' ? '支付宝' : '微信'))
|
||
const qrAlt = computed(() => (payType.value === 'alipay' ? '支付宝支付二维码' : '微信支付二维码'))
|
||
|
||
const form = reactive({ contactName: '', contactPhone: '', contactEmail: '', quantity: 1 })
|
||
const redirect = computed(() => `/buy?id=${id}`)
|
||
|
||
/**
|
||
* 支付确认一律以**渠道侧**为准(轮询 + 手动按钮都走它)。
|
||
* 这里刻意不再调用 `mark-paid`:那个接口不查单,用户点一下就能把订单置为已支付。
|
||
* 状态词表由后端按渠道归一,前端无需知道支付宝的 trade_status(见 ADR 0010)。
|
||
*/
|
||
const {
|
||
paid,
|
||
querying,
|
||
error: payError,
|
||
expired: payExpired,
|
||
start: startPayPolling,
|
||
query: queryPayStatus
|
||
} = useHjcPayStatus(() => redirect.value, () => payType.value)
|
||
|
||
/** 渠道侧确认已付款后,页面自己走到完成态——用户不必再点任何按钮 */
|
||
watch(paid, (v) => {
|
||
if (v) step.value = 'done'
|
||
})
|
||
|
||
function formatMoney(n?: number | string) {
|
||
return Number(n ?? 0).toFixed(2)
|
||
}
|
||
|
||
/** 切换支付方式:清掉上一张码再重新发起,避免两种支付信息同时留在页面上 */
|
||
async function switchPayType(next: 'wechat' | 'alipay') {
|
||
if (next === payType.value) return
|
||
payType.value = next
|
||
codeUrl.value = ''
|
||
error.value = ''
|
||
await refreshQr()
|
||
}
|
||
|
||
async function submit() {
|
||
error.value = ''
|
||
submitting.value = true
|
||
try {
|
||
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
|
||
const order: any = await $fetch('/api/tender/order', {
|
||
method: 'POST',
|
||
body: {
|
||
projectId: Number(id),
|
||
quantity: form.quantity || 1,
|
||
contactName: form.contactName,
|
||
contactPhone: form.contactPhone,
|
||
contactEmail: form.contactEmail
|
||
}
|
||
})
|
||
const orderAuth = await handleAuthCode(order, redirect.value)
|
||
if (orderAuth.handled) {
|
||
// 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token
|
||
error.value = orderAuth.message || ''
|
||
return
|
||
}
|
||
if (order?.code !== 0) {
|
||
error.value = order?.message || '下单失败'
|
||
return
|
||
}
|
||
orderNo.value = order.data?.orderNo || ''
|
||
totalAmount.value = order.data?.totalAmount || 0
|
||
// 发起支付
|
||
const payRes: any = await $fetch('/api/tender/pay', {
|
||
method: 'POST',
|
||
body: { orderNo: orderNo.value, payType: payType.value }
|
||
})
|
||
const payAuth = await handleAuthCode(payRes, redirect.value)
|
||
if (payAuth.handled) {
|
||
error.value = payAuth.message || ''
|
||
return
|
||
}
|
||
// 订单已创建:即使发起支付失败也停在支付步骤,避免用户重提交产生重复订单
|
||
step.value = 'pay'
|
||
if (payRes?.code === 0) {
|
||
codeUrl.value = payRes.data?.codeUrl || ''
|
||
if (!codeUrl.value) error.value = '未获取到支付信息,请稍后重试'
|
||
} else {
|
||
error.value = payRes?.message || '发起支付失败,请稍后重试'
|
||
}
|
||
// 无论这次有没有拿到支付码都开始查单:订单可能**已经**在渠道侧付过了
|
||
//(例如上一次支付成功但页面被关掉),查单会把它认出来并直接进完成态。
|
||
startPayPolling(orderNo.value)
|
||
} catch (e: any) {
|
||
error.value = e?.data?.message || e?.message || '下单失败'
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 手动确认(二维码扫不了时的兜底):**也走查单**,不代替渠道下结论。
|
||
* 渠道侧没收到钱就明确这么说,而不是像改造前那样把它当成"已支付"。
|
||
*/
|
||
async function confirmPaid() {
|
||
error.value = ''
|
||
confirming.value = true
|
||
try {
|
||
const res = await queryPayStatus()
|
||
if (res.handled) {
|
||
// 401 已跳登录页;403 只提示
|
||
error.value = res.message || ''
|
||
return
|
||
}
|
||
if (res.settled) {
|
||
step.value = 'done'
|
||
return
|
||
}
|
||
error.value = res.message || `${channelLabel.value}侧还未收到付款,请完成扫码支付后再试`
|
||
} finally {
|
||
confirming.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重新获取支付码 / 切换渠道后重新发起。
|
||
*
|
||
* 允许对同一 `out_trade_no`(这里就是 hjc 订单号)重复发起下单,
|
||
* 所以"刷新"是重新调 `/api/tender/pay` 拿一张新码,并**重置 2 小时计时**。
|
||
*
|
||
* 把当前 `payType` 一并带上:切换渠道时必须让后端按新渠道下单,
|
||
* 否则会拿着一张微信的码却显示在"支付宝"标签下。
|
||
*/
|
||
async function refreshQr() {
|
||
error.value = ''
|
||
refreshing.value = true
|
||
try {
|
||
const res: any = await $fetch('/api/tender/pay', {
|
||
method: 'POST',
|
||
body: { orderNo: orderNo.value, payType: payType.value }
|
||
})
|
||
const auth = await handleAuthCode(res, redirect.value)
|
||
if (auth.handled) {
|
||
error.value = auth.message || ''
|
||
return
|
||
}
|
||
if (res?.code !== 0) {
|
||
error.value = res?.message || '获取支付信息失败,请稍后重试'
|
||
return
|
||
}
|
||
codeUrl.value = res.data?.codeUrl || ''
|
||
if (res.data?.amount) totalAmount.value = Number(res.data.amount)
|
||
if (!codeUrl.value) {
|
||
error.value = '未获取到支付信息,请稍后重试'
|
||
return
|
||
}
|
||
startPayPolling(orderNo.value)
|
||
} catch (e: any) {
|
||
error.value = e?.data?.message || e?.message || '获取支付信息失败,请稍后重试'
|
||
} finally {
|
||
refreshing.value = false
|
||
}
|
||
}
|
||
|
||
loggedIn.value = isLoggedIn()
|
||
|
||
try {
|
||
if (!id) {
|
||
notFound.value = true
|
||
} else {
|
||
const res: any = await $fetch(`/api/tender/detail?id=${id}`)
|
||
if (res && res.id) tender.value = res
|
||
else notFound.value = true
|
||
}
|
||
} catch {
|
||
notFound.value = true
|
||
}
|
||
</script>
|