Files
hjc-web/app/templates/template-07/pages/BuyDocument.vue
T
weicw1996 5b6e1b67bc feat(tender): 支付链接改为站内二维码,支付确认改走微信侧查单
- BuyDocument:支付步骤原先把后端 Native 下单返回的 codeUrl(weixin://wxpay/...)当明文
  链接渲染成一个 <a target="_blank">,而 weixin:// 在浏览器里点击没有任何有效行为——标题写
  着「请使用微信扫码支付」,页面上却没有二维码,PC 端 Native 支付实质不可用。改为渲染真正
  的二维码,并删掉那个链接。
- HjcPayQrcode(新):uqr 站内生成,renderSVG() → data:image/svg+xml → <img :src>。不用
  v-html(避开 vue/no-v-html),不用第三方外链(hjc-h5 用的 api.qrserver.com 在境内不可靠,
  且等于把支付凭据交给第三方),也不改后端契约。
- useHjcPayStatus(新):支付结果一律以**微信侧**为准——轮询 pay-status(3s,页面隐藏暂停,
  2 小时上限),手动「我已完成支付」按钮也走同一条查单通道。原先只有 mark-paid 一条路,而它
  不向微信查单(HjcOrderController 的 javadoc 自陈「订单置为已支付只有 /mark-paid 一条路」),
  用户一分钱不付、点一下就能把订单置为已支付并触发一站式推送。故 hjc-web 弃用它,并删除
  server/api/tender/mark-paid.put.ts,不留这个入口给下一个人误用。
- 查单的另一好处:微信已支付而本地仍未支付时它会就地补写状态(幂等)。于是「付了钱但页面被
  关掉/断网」这类漏单能被认出来——进入支付步骤即开始轮询,正是为了认出这种订单。
- 拿不到二维码时(例如租户支付配置错误)不再显示空白卡片,而是失败原因 +「重新获取二维码」。
  微信 Native 码默认 2 小时且服务端不下发 expireTime,故按发起支付时刻本地计时,过期把二维码
  置灰并提示重新获取;「重新获取二维码」重调 /api/tender/pay(同 out_trade_no 的 Native→
  Native 重下微信允许)。
- 由此 hjc-web 与 hjc-h5/小程序端的支付确认语义**有意不一致**(那两端仍在用 mark-paid,改动
  它们等于同时动三端线上支付路径)。理由与边界记在 ADR 0009。
- 依赖:新增 uqr;两份锁文件同步更新(pnpm 装,package-lock.json 同步)。
- 删除 BUY_TENDER_MAP_REPORT.md:530 行历史快照,结论与现状全面矛盾(称购买流程是桩函数、
  /buy 不可用、没有登录流程、pay 代理路径写错),已实际误导过一次调研;历史价值留在 git 里。
2026-09-17 02:32:39 +08:00

270 lines
10 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">请使用微信扫码支付</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>
<template v-if="codeUrl">
<!-- 过期只是置灰提示仍留在页面上万一用户是刚过点就扫的还能看见自己扫的是哪张码 -->
<div :class="payExpired ? 'opacity-30' : ''">
<HjcPayQrcode :value="codeUrl" :size="240" />
</div>
<p class="mt-3 text-sm text-gray-500">请在 2 小时内用微信扫描上方二维码完成支付</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 form = reactive({ contactName: '', contactPhone: '', contactEmail: '', quantity: 1 })
const redirect = computed(() => `/buy?id=${id}`)
/**
* 支付确认一律以**微信侧**为准(轮询 + 手动按钮都走它)。
* 这里刻意不再调用 `mark-paid`:那个接口不查单,用户点一下就能把订单置为已支付(见 ADR 0009)。
*/
const {
paid,
querying,
error: payError,
expired: payExpired,
start: startPayPolling,
query: queryPayStatus
} = useHjcPayStatus(() => redirect.value)
/** 微信侧确认已付款后,页面自己走到完成态——用户不必再点任何按钮 */
watch(paid, (v) => {
if (v) step.value = 'done'
})
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
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 } })
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 || ''
} 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 || '微信侧还未收到付款,请完成扫码支付后再试'
} finally {
confirming.value = false
}
}
/**
* 重新获取支付二维码。
*
* 微信允许对同一 `out_trade_no`(这里就是 hjc 订单号)重复发起 Native 下单,
* 所以"刷新二维码"是重新调 `/api/tender/pay` 拿一张新码,并**重置 2 小时计时**。
*/
async function refreshQr() {
error.value = ''
refreshing.value = true
try {
const res: any = await $fetch('/api/tender/pay', { method: 'POST', body: { orderNo: orderNo.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>