Files
hjc-web/app/templates/template-07/pages/BuyDocument.vue
T
weicw-dshsa 5e1106d8ce feat: 购标流程(/buy)支持支付宝支付
上一轮我只改了独立收银台 `/tender/pay`,**漏了购标流程**——
`/buy` 走的是 `templates/template-07/pages/BuyDocument.vue`,
它自己那一套支付步骤同样硬编码微信:「提交订单并用微信支付」、
「请使用微信扫码支付」,且调 `/api/tender/pay` 不传 payType。
结果是用户从标书详情页买标书(主路径)根本看不到支付宝入口。

改动(与收银台保持同一套交互,便于对照):
- 支付步骤加支付方式选择器(微信支付 / 支付宝),默认微信,
  保持接入前行为不变。
- 支付宝走电脑网站支付:后端返回自提交表单,`document.write` 进新窗口。
  用 document.write 而非 v-html——后者插入的脚本标签不会执行(HTML 规范),
  表单永远不会自动提交。原页面留在原标签页继续轮询查单。
- 弹窗被拦截时给出可行动提示,并用「前往支付宝付款」按钮兜底重试。
- 切换支付方式清掉上一张码,避免两张码同时留在页面上。
- 两处 `/api/tender/pay` 调用(submit 与 refreshQr)都带上 payType。
- 文案按渠道取词(channelLabel / qrAlt),状态判定仍走渠道无关的
  useHjcPayStatus,不引入支付宝词表。

验证:eslint 0 error;npm run build:dev 通过。
2026-09-20 20:44:29 +08:00

373 lines
15 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>
<!-- 支付宝电脑网站支付:后端返回自提交表单,写进新页面即跳转账单 -->
<template v-if="alipayForm">
<div class="rounded-md bg-blue-50 px-4 py-6 text-sm text-blue-700">
已为你打开支付宝收银台。若新页面没有自动打开,请点击下方按钮。
</div>
<button
class="mt-6 w-full rounded-md bg-blue-600 py-3 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="confirming"
@click="openAlipayForm"
>
前往支付宝付款
</button>
<button
class="mt-3 w-full rounded-md border border-gray-300 py-3 font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
:disabled="confirming"
@click="confirmPaid"
>
{{ confirming ? '确认中...' : '我已完成支付,确认订单' }}
</button>
</template>
<template v-else-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 && !alipayForm"
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
v-if="!alipayForm"
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 alipayForm = ref('')
/** 提示文案里的渠道名(只影响措辞,不影响判定) */
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)
}
/**
* 把支付宝返回的自提交表单写进一个新页面。
*
* 用 `document.write` 而不是 `v-html`:表单里带一段自动提交的脚本,
* 而 `v-html` 插入的脚本标签**不会执行**(HTML 规范如此),表单永远不会自动提交。
* 写进新窗口则脚本正常执行,且原页面保留在原标签页继续轮询查单。
*
* 注意:本注释不要写出脚本标签的字面量——SFC 的 script 块会被它提前闭合。
*/
function openAlipayForm() {
if (!alipayForm.value) return
const w = window.open('', '_blank')
if (!w) {
// 弹窗被拦截:留在本页提示用户点按钮重试(按钮复用它)
error.value = '浏览器拦截了新窗口,请允许弹出窗口后点「前往支付宝付款」'
return
}
w.document.open()
w.document.write(alipayForm.value)
w.document.close()
error.value = ''
}
/** 切换支付方式:清掉上一张码再重新发起,避免两种支付信息同时留在页面上 */
async function switchPayType(next: 'wechat' | 'alipay') {
if (next === payType.value) return
payType.value = next
codeUrl.value = ''
alipayForm.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 || ''
alipayForm.value = payRes.data?.alipayForm || ''
if (alipayForm.value) openAlipayForm()
} 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 || ''
alipayForm.value = res.data?.alipayForm || ''
if (res.data?.amount) totalAmount.value = Number(res.data.amount)
if (alipayForm.value) {
openAlipayForm()
} else 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>