Files
hjc-web/app/pages/tender/pay.vue
T
weicw-dshsa 9587a27ede feat(order): 收银台支付倒计时,超时锁死并引导重新下单
- 到期时刻由后端下发(payDeadline),倒计时与"到点锁页面"都用它,前端不自己算时限
- 解析后端时间时把空格换成 T:后端格式是 `yyyy-MM-dd HH:mm:ss`,
  而 iOS Safari / 微信内置浏览器解析不了不带 T 的写法(返回 NaN,
  会让倒计时与锁页在最主要的那一端静默失效)
- 修正收银台写死的「2 小时」文案:有效期由后端配置,页面不该复述一个具体数字
- 订单列表/详情:超时后不再给「去支付」,并显示「已取消(支付超时)」
- `hjcCanActOn` 收成共享导出,两个页面不再各写一份判定
- 与 hjc-h5 的 utils/orderStatus 保持同一套定义(两仓逐字对齐)
2026-09-29 17:44:59 +08:00

452 lines
20 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">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">收银台</h1>
<HjcUserBar />
</div>
<div v-if="!loggedIn" class="py-20 text-center">
<p class="text-gray-500">请先登录后继续支付</p>
<NuxtLink :to="`/login?redirect=${loginRedirect}`" class="mt-4 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">去登录</NuxtLink>
</div>
<div v-else-if="!orderNo" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
<p class="text-gray-700">缺少订单号,无法发起支付</p>
<NuxtLink to="/tender/orders" class="mt-6 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">返回我的订单</NuxtLink>
</div>
<SiteLoading v-else-if="loading" />
<div v-else-if="loadError" class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
<p class="text-red-500">{{ loadError }}</p>
<NuxtLink to="/tender/orders" class="mt-6 inline-block rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50">返回我的订单</NuxtLink>
</div>
<!--
终态:已支付 / 已取消 / 已退款。
**这一块必须先于发起支付判定**——进一张已支付的单却先拉起一次统一下单,既无意义,
又会多撞一次跨端 OUT_TRADE_NO_USED(多一次失败尝试不改变结论)。
-->
<div v-else-if="finalState" class="mx-auto mt-6 max-w-2xl rounded-lg border p-8 text-center" :class="finalState.cls">
<p class="text-xl font-bold">{{ finalState.title }}</p>
<p class="mt-2 text-sm opacity-80">订单号:{{ orderNo }}</p>
<NuxtLink to="/tender/orders" class="mt-6 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">返回我的订单</NuxtLink>
</div>
<!--
支付时限已到(但后端还没把它落定为已取消)。
**必须锁死,不给「重新发起支付」**:同一个订单号对同一渠道重复统一下单会撞
OUT_TRADE_NO_USED,而且渠道侧那张支付单此刻已随支付时限一同失效(ADR 0019)——
再出一张码要么被渠道拒绝,要么给出一张我们马上就要取消的订单的码。
超时 = 这张订单终了,想要就重新下单。
-->
<div v-else-if="timedOut" class="mx-auto mt-6 max-w-2xl rounded-lg border border-amber-200 bg-amber-50 p-8 text-center text-amber-800">
<p class="text-xl font-bold">支付已超时</p>
<p class="mt-2 text-sm opacity-80">该订单因支付超时已取消,如需购买请重新下单。</p>
<p class="mt-1 text-sm opacity-80">订单号:{{ orderNo }}</p>
<div class="mt-6 flex items-center justify-center gap-3">
<NuxtLink :to="projectLink" class="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">重新下单</NuxtLink>
<NuxtLink to="/tender/orders" class="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50">返回我的订单</NuxtLink>
</div>
</div>
<!-- 待支付:进入即自动发起支付(与购标流程的支付步骤、h5 收银台一致) -->
<div v-else class="mx-auto mt-6 max-w-2xl rounded-lg border border-gray-200 p-8 text-center">
<h2 class="text-xl font-bold text-gray-900">请使用{{ channelLabel }}支付</h2>
<p class="mt-2 text-sm text-gray-500">订单号:{{ orderNo }}</p>
<p class="mt-1 text-sm text-gray-500">{{ order?.projectName }}</p>
<!--
倒计时:驱动值来自**后端下发的 payDeadline**,不是前端拿创建时刻自己加时限。
它是「这张码还有多久失效」的唯一告知渠道——不给它,用户会拿一张已经不能付的码去扫。
没下发到期时刻(旧订单/字段缺失)时不显示,也不锁页面。
-->
<p v-if="remainText" class="mt-3 text-sm" :class="remainMs !== null && remainMs <= 60_000 ? 'text-red-500' : 'text-gray-500'">
支付剩余时间 <span class="font-mono font-semibold">{{ remainText }}</span>
</p>
<!-- 金额一律来自**订单本身**(by-no),不来自支付响应:
支付一失败页面就会停在 0.00,而收银台恰恰是最需要看到金额的地方 -->
<p class="my-4 text-2xl font-bold text-blue-600">¥{{ formatMoney(amount) }}</p>
<!--
支付方式选择。**切换方式即重新发起支付**——同一订单号不能换支付类型重复下单
(微信会回 OUT_TRADE_NO_USED),所以切换前把上一张码清掉,避免两张码同时留在页面上。
-->
<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="starting || refreshing"
@click="switchPayType(opt.value)"
>
{{ opt.label }}
</button>
</div>
<!--
支付宝走**电脑网站支付**:后端返回一段自提交表单,写进新页面即跳转支付宝收银台
(当面付扫码需单独签约并提交资料审核,见 ADR 0010 补记,故不作默认)。
微信仍是出码:两者在模板里各占一支。
-->
<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>
<!--
这里**不写具体时长**:支付时限由后端配置、且随订单剩余窗口变化,
页面上写死一个数字(原先写的是 2 小时)迟早与上面那行倒计时自相矛盾。
-->
<p class="mt-3 text-sm text-gray-500">请在支付时限内用{{ 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">
{{ starting ? `正在获取${channelLabel}支付` : '暂未获取到支付信息,请点击下方按钮重新获取' }}
</div>
<button
v-if="codeUrl && !alipayForm"
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="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 || starting"
@click="refreshQr"
>
{{ (refreshing || starting) ? '获取中...' : (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 class="mt-6 border-t border-gray-100 pt-4">
<NuxtLink to="/tender/orders" class="text-sm text-blue-600 hover:underline">← 返回我的订单</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
hjcFormatCountdown,
hjcIsTimeoutCancelled,
hjcOrderStatusKey,
hjcPayDeadlineMs,
hjcPayRemainMs
} from '~/utils/orderStatus'
/**
* 收银台:买家为一张**已创建的订单**付款的独立页面(CONTEXT.md 的「收银台」)。
*
* 与购标流程(`/buy` 的 BuyDocument `step==='pay'`)的分界:购标流程是「先建单、随即付款」,
* 收银台是事后回到某张待支付订单继续付款。**本轮不重构 BuyDocument**,
* 两份实现共享的只有 `HjcPayQrcode` 与 `useHjcPayStatus`(重活已经都在那里了)。
*
* 本页只服务微信外(ADR 0010):不因 UA 在微信内就跳 hjc-h5。
* 支付方式可选**微信扫码**或**支付宝**;微信出二维码、支付宝走电脑网站支付
* (返回自提交表单,跳转支付宝收银台)。两者共用同一个查单轮询与状态判定。
*/
const route = useRoute()
const { isLoggedIn, handleAuthCode } = useHjcAuth()
/** 收银台只认订单号(凭它取应付金额),没有自增 id */
const orderNo = computed(() => String(route.query.orderNo || ''))
const loginRedirect = computed(() => encodeURIComponent(route.fullPath))
const loggedIn = ref(false)
/** by-no 取单中 */
const loading = ref(false)
/** by-no 失败(订单不存在/无权限/网络),与"发起支付失败"分开 */
const loadError = ref('')
const order = shallowRef<any | null>(null)
/** 终态(已完成/已取消/已退款):终态下**绝不发起支付** */
const finalState = shallowRef<{ title: string; cls: string } | null>(null)
/** 当前支付方式。默认微信,与接入支付宝之前的行为一致 */
const payType = ref<'wechat' | 'alipay'>('wechat')
const payOptions = [
{ value: 'wechat' as const, label: '微信支付' },
{ value: 'alipay' as const, label: '支付宝' }
]
const codeUrl = ref('')
/** 支付宝电脑网站支付的自提交表单(后端只在该渠道下返回) */
const alipayForm = ref('')
const amount = ref(0)
const starting = ref(false)
const refreshing = ref(false)
const confirming = ref(false)
const error = ref('')
/** 提示文案里的渠道名(只影响措辞,不影响判定) */
const channelLabel = computed(() => (payType.value === 'alipay' ? '支付宝' : '微信'))
const qrAlt = computed(() => (payType.value === 'alipay' ? '支付宝支付二维码' : '微信支付二维码'))
/**
* 支付确认一律以**渠道侧**为准(轮询 + 手动按钮都走它)。
* 绝不调用 `mark-paid`:那个接口不查单,点一下就能把订单置为已支付(见 ADR 0010)。
*/
const {
paid,
querying,
error: payError,
expired: payExpired,
start: startPayPolling,
query: queryPayStatus
} = useHjcPayStatus(() => route.fullPath, () => payType.value)
// 渠道侧确认收款后跳订单列表(与 h5 收银台的 redirectTo 列表对齐)
watch(paid, async (v) => {
if (v) await navigateTo('/tender/orders')
})
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
/**
* 倒计时用的"当前时刻"。
*
* 每秒只更新一个 ref(而不是让每个用到它的地方各自 setInterval),
* 且**秒级**足够了:支付时限是 30 分钟量级,没必要为 1 秒的精度付出多次渲染。
*/
const now = ref(Date.now())
let clock: ReturnType<typeof setInterval> | null = null
const remainMs = computed(() => hjcPayRemainMs(order.value, now.value))
const remainText = computed(() => (remainMs.value == null ? '' : hjcFormatCountdown(remainMs.value)))
/** 到期时刻已知且已归零 —— 页面锁死,不再出码、不再轮询 */
const timedOut = computed(() => remainMs.value !== null && remainMs.value <= 0)
/** 「重新下单」的目标:回到该项目详情(不是购标流程,购标流程会再建一张新单) */
const projectLink = computed(() => {
const id = order.value?.projectId
return id ? `/tender/${id}` : '/tender'
})
/** 状态口径与订单列表同源(`~/utils/orderStatus`),避免两处各判一次导致分叉 */
function terminalStateOf(o: any) {
switch (hjcOrderStatusKey(o)) {
case 'done':
return { title: '该订单已完成支付', cls: 'border-green-200 bg-green-50 text-green-700' }
case 'cancel':
// 超时取消说清是谁取消的:用户什么都没干,只显示「已取消」会让人以为是自己误触的
return {
title: hjcIsTimeoutCancelled(o) ? '该订单因支付超时已取消' : '该订单已取消',
cls: 'border-gray-200 bg-gray-50 text-gray-600'
}
case 'refund':
return { title: '该订单已退款', cls: 'border-gray-200 bg-gray-50 text-gray-600' }
default:
return null
}
}
/**
* 渠道错误码 → 可行动的中文提示。
*
* `OUT_TRADE_NO_USED` = 同一订单号换了支付类型重复下单。微信内(h5 → 小程序,JSAPI)与
* PC 端(Native)各发起一次时必现,而"从订单列表回到旧单支付"这条路径正是本轮新开的。
* 错误码由后端从支付异常的 cause 链上取,前端**不要**去匹配 message 字符串——
* 那种写法会在错误包装方式一改时静默失效。
*/
function payErrorMessage(res: any) {
if (res?.data?.wechatCode === 'OUT_TRADE_NO_USED') {
return '该订单已在微信内发起过支付,请回到微信内的收银台继续完成付款。'
}
return res?.message || '发起支付失败,请稍后重试'
}
async function startPay() {
// 已过支付时限就不出码:后端也会拒(它按「本单剩余窗口」算,剩余不足一分钟即拒绝),
// 前端先拦住只是省一次注定失败的请求,并让页面停在"支付已超时"而不是闪一下错误。
if (timedOut.value) return
error.value = ''
starting.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, route.fullPath)
if (auth.handled) {
error.value = auth.message || ''
return
}
if (res?.code === 0) {
codeUrl.value = res.data?.codeUrl || ''
alipayForm.value = res.data?.alipayForm || ''
if (res.data?.amount) amount.value = Number(res.data.amount)
if (!codeUrl.value && !alipayForm.value) {
error.value = '未获取到支付信息,请稍后重试'
} else if (alipayForm.value) {
// 网页支付:立刻把表单送去新页面,用户不必再点一次
openAlipayForm()
}
} else {
error.value = payErrorMessage(res)
}
// 无论这次有没有拿到支付码都开始查单:订单可能**已经**在渠道侧付过了
//(例如在微信内由小程序付掉、或上一次支付成功但页面被关掉),查单会把它认出来并跳列表。
// 到期时刻传后端下发的那个:让轮询的终点与后端判定超时、渠道单失效对齐(ADR 0019)。
if (orderNo.value) startPayPolling(orderNo.value, hjcPayDeadlineMs(order.value))
} catch (e: any) {
error.value = e?.data?.message || e?.message || '发起支付失败,请稍后重试'
} finally {
starting.value = false
}
}
/**
* 把支付宝返回的自提交表单写进一个新页面。
*
* 用 `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 startPay()
}
/** 重新获取二维码:允许对同一订单号重复发起下单,顺带重置计时 */
async function refreshQr() {
await startPay()
}
/** 手动确认(扫码后的兜底):**也走查单**,不代替渠道下结论 */
async function confirmPaid() {
error.value = ''
confirming.value = true
try {
const res = await queryPayStatus()
if (res.handled) {
error.value = res.message || ''
return
}
if (res.settled) {
await navigateTo('/tender/orders')
return
}
error.value = res.message || `${channelLabel.value}侧还未收到付款,请完成支付后再试`
} finally {
confirming.value = false
}
}
loggedIn.value = isLoggedIn()
onMounted(async () => {
if (!loggedIn.value || !orderNo.value) return
loading.value = true
try {
// 客户端发起:这里用裸 $fetch 是安全的(浏览器会带 Cookie)。
// 服务端渲染期间才必须用 useRequestFetch——本页的取数刻意放在 onMounted,规避那个坑。
const res: any = await $fetch('/api/tender/order-by-no', { query: { orderNo: orderNo.value } })
const auth = await handleAuthCode(res, route.fullPath)
if (auth.handled) {
loadError.value = auth.message || ''
return
}
if (res?.code !== 0) {
loadError.value = res?.message || '订单加载失败'
return
}
const o = res.data
order.value = o
// 金额来自订单本身,与"发起支付"解耦
amount.value = Number(o?.totalAmount ?? 0)
const terminal = terminalStateOf(o)
if (terminal) {
finalState.value = terminal
return
}
// 已在这张单上发起过支付宝支付,就默认用支付宝继续——避免用户上次选了支付宝、
// 这次进来又被默认拉回微信,撞一次渠道侧的「订单号已使用」
if (typeof o?.payMethod === 'string' && o.payMethod.startsWith('ALIPAY')) {
payType.value = 'alipay'
}
loading.value = false
// 已过支付时限:锁在"支付已超时"那一屏,不出码、不轮询
if (timedOut.value) return
// 只有"确实还没付、也没取消"才发起支付
await startPay()
} catch (e: any) {
loadError.value = e?.data?.message || e?.message || '订单加载失败'
} finally {
loading.value = false
}
})
onMounted(() => {
// 秒级 ticking 只服务倒计时显示;真正的"到点锁页面"由 timedOut 这个 computed 判定,
// 所以哪怕这一拍晚了几百毫秒,也不会出现"倒计时 00:00 了还能付款"。
clock = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (clock) {
clearInterval(clock)
clock = null
}
})
</script>