Files
hjc-web/app/templates/template-07/pages/BuyDocument.vue
T
weicw-dshsa 1b4dbda28e feat: PC 两处支付入口的支付宝统一改为扫码出码(与微信交互一致)
用户选择: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 通过。
2026-09-20 21:16:07 +08:00

326 lines
13 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>
<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>