feat(order): 收银台支付倒计时,超时锁死并引导重新下单

- 到期时刻由后端下发(payDeadline),倒计时与"到点锁页面"都用它,前端不自己算时限
- 解析后端时间时把空格换成 T:后端格式是 `yyyy-MM-dd HH:mm:ss`,
  而 iOS Safari / 微信内置浏览器解析不了不带 T 的写法(返回 NaN,
  会让倒计时与锁页在最主要的那一端静默失效)
- 修正收银台写死的「2 小时」文案:有效期由后端配置,页面不该复述一个具体数字
- 订单列表/详情:超时后不再给「去支付」,并显示「已取消(支付超时)」
- `hjcCanActOn` 收成共享导出,两个页面不再各写一份判定
- 与 hjc-h5 的 utils/orderStatus 保持同一套定义(两仓逐字对齐)
This commit is contained in:
2026-09-29 17:44:59 +08:00
parent 4bb349fd56
commit 9587a27ede
6 changed files with 225 additions and 18 deletions
+15 -7
View File
@@ -13,11 +13,11 @@
*/
/**
* 支付码的有效期。
* 支付码有效期的**兜底**值(从调用 `start` 的那一刻起算)。
*
* 微信 Native 默认 2 小时;支付宝当面付的超时由下单时的 `timeout_express` 决定。
* 服务端都不下发过期时刻(`PaymentResponse` 不设 `expireTime`),
* 所以只能以「本次拿到支付码的时刻」本地计时;到点停止轮询并提示重新获取。
* 优先用后端随订单下发的 `payDeadline`——那才是权威值,且与后端判定超时、渠道单失效
* 是同一个时刻(见 ADR 0019),`start` 的第二个参数就是传它。
* 拿不到到期时刻时才退回这个本地计时(例如购标流程里还没取到订单对象就被调用)。
*/
export const HJC_PAY_QR_TTL_MS = 2 * 60 * 60 * 1000
@@ -143,14 +143,22 @@ export function useHjcPayStatus(
}
}
/** 开始(或重新开始)轮询,并重置 2 小时计时 */
function start(no: string) {
/**
* 开始(或重新开始)轮询。
*
* @param no 订单号
* @param deadlineAtMs 该订单支付时限的到期时刻(毫秒)。**传后端下发的 `payDeadline`**——
* 用本地倒计时代替它,两者会在边界上错开。不传则退回 {@link HJC_PAY_QR_TTL_MS}
*/
function start(no: string, deadlineAtMs?: number | null) {
orderNo = no
paid.value = false
expired.value = false
error.value = ''
status.value = null
deadline = Date.now() + HJC_PAY_QR_TTL_MS
deadline = typeof deadlineAtMs === 'number' && Number.isFinite(deadlineAtMs)
? deadlineAtMs
: Date.now() + HJC_PAY_QR_TTL_MS
active = true
pauseTimer()
ensureTimer()
+17 -3
View File
@@ -66,8 +66,12 @@
<HjcInvoiceFiles :files="order.invoices" />
</div>
<!-- 待支付订单的操作区(与列表页同一判定:hjcCanActOnOrder) -->
<div v-if="hjcCanActOnOrder(order)" class="mt-4 flex justify-end gap-3 rounded-lg border border-gray-200 p-5">
<!--
待支付订单的操作区(与列表页同一判定)。
`canAct` = hjcCanActOnOrder 再排除「支付时限已到」——已超时的单后端会拒绝出码,
留一个点了必然失败的「去支付」只会让人白点一次。
-->
<div v-if="canAct" class="mt-4 flex justify-end gap-3 rounded-lg border border-gray-200 p-5">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
@@ -178,7 +182,11 @@
<script setup lang="ts">
import type { HjcOrder } from '~/types/tender'
import { hjcCanActOnOrder, hjcOrderStatusClass, hjcOrderStatusLabel } from '~/utils/orderStatus'
import {
hjcCanActOn,
hjcOrderStatusClass,
hjcOrderStatusLabel
} from '~/utils/orderStatus'
/**
* 订单详情页(PC 端)。
@@ -215,6 +223,12 @@ const cancelling = ref(false)
const isPaid = computed(() => order.value?.payStatus === 1)
const payMethodLabel = computed(() => (order.value?.payMethod === 'ALIPAY' ? '支付宝' : '微信支付'))
/**
* 还能不能操作(去支付 / 取消):共享判定,**不在这里重写一遍**——
* 两个页面各写一份必然分叉,出现"列表里不能点、详情里能点"。
*/
const canAct = computed(() => hjcCanActOn(order.value))
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
+8 -2
View File
@@ -63,8 +63,10 @@
<!--
待支付订单的操作区。判定用 hjcCanActOnOrder(payStatus===0 && orderStatus!==2),
**不用** statusKey==='pending'——payStatus=2(支付失败)也会落进 pending 兜底。
再排除「支付时限已到」:那种单后端会拒绝出码、渠道单也已随同一时刻失效(ADR 0019),
留一个点了必然失败的「去支付」只会让人白点一次。
-->
<div v-if="hjcCanActOnOrder(o)" class="flex justify-end gap-3">
<div v-if="canAct(o)" class="flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
@@ -103,7 +105,11 @@
</template>
<script setup lang="ts">
import { hjcCanActOnOrder, hjcOrderStatusClass, hjcOrderStatusLabel } from '~/utils/orderStatus'
import {
hjcCanActOn,
hjcOrderStatusClass,
hjcOrderStatusLabel
} from '~/utils/orderStatus'
const { isLoggedIn, handleAuthCode } = useHjcAuth()
/**
+86 -5
View File
@@ -33,11 +33,37 @@
<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>
@@ -92,7 +118,11 @@
<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>
<!--
这里**不写具体时长**:支付时限由后端配置、且随订单剩余窗口变化,
页面上写死一个数字(原先写的是 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">
@@ -130,7 +160,13 @@
</template>
<script setup lang="ts">
import { hjcOrderStatusKey } from '~/utils/orderStatus'
import {
hjcFormatCountdown,
hjcIsTimeoutCancelled,
hjcOrderStatusKey,
hjcPayDeadlineMs,
hjcPayRemainMs
} from '~/utils/orderStatus'
/**
* 收银台:买家为一张**已创建的订单**付款的独立页面(CONTEXT.md 的「收银台」)。
@@ -201,13 +237,37 @@ 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: '该订单已取消', cls: 'border-gray-200 bg-gray-50 text-gray-600' }
// 超时取消说清是谁取消的:用户什么都没干,只显示「已取消」会让人以为是自己误触的
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:
@@ -231,6 +291,9 @@ function payErrorMessage(res: any) {
}
async function startPay() {
// 已过支付时限就不出码:后端也会拒(它按「本单剩余窗口」算,剩余不足一分钟即拒绝),
// 前端先拦住只是省一次注定失败的请求,并让页面停在"支付已超时"而不是闪一下错误。
if (timedOut.value) return
error.value = ''
starting.value = true
try {
@@ -258,7 +321,8 @@ async function startPay() {
}
// 无论这次有没有拿到支付码都开始查单:订单可能**已经**在渠道侧付过了
//(例如在微信内由小程序付掉、或上一次支付成功但页面被关掉),查单会把它认出来并跳列表。
if (orderNo.value) startPayPolling(orderNo.value)
// 到期时刻传后端下发的那个:让轮询的终点与后端判定超时、渠道单失效对齐(ADR 0019)。
if (orderNo.value) startPayPolling(orderNo.value, hjcPayDeadlineMs(order.value))
} catch (e: any) {
error.value = e?.data?.message || e?.message || '发起支付失败,请稍后重试'
} finally {
@@ -358,8 +422,10 @@ onMounted(async () => {
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 || '订单加载失败'
@@ -367,4 +433,19 @@ onMounted(async () => {
loading.value = false
}
})
onMounted(() => {
// 秒级 ticking 只服务倒计时显示;真正的"到点锁页面"由 timedOut 这个 computed 判定,
// 所以哪怕这一拍晚了几百毫秒,也不会出现"倒计时 00:00 了还能付款"。
clock = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (clock) {
clearInterval(clock)
clock = null
}
})
</script>
+11
View File
@@ -133,6 +133,17 @@ export interface HjcOrder {
payTime?: string
payStatus?: number
orderStatus?: number
/** 取消来源:`BUYER`(买家取消)/ `TIMEOUT`(超时取消),仅已取消订单有值 */
cancelSource?: string | null
/** 取消时间 */
cancelTime?: string | null
/**
* 支付时限的到期时刻(仅**仍待支付**的订单由后端下发)。
*
* 它是算出来的(创建时刻 + 配置时限),不是一列。收银台靠它显示倒计时——
* 前端不要自己拿 `createTime` 加时限,两边各算一次会让「倒计时归零」与「后端判定超时」错开。
*/
payDeadline?: string | null
createTime?: string
/** 仅已支付订单由后端下发 */
tenderFiles?: HjcTenderFile[] | null
+88 -1
View File
@@ -18,10 +18,26 @@
/** 订单状态归类键。四端(web / h5 / 小程序 / 后台)未来的展示都应落在这四个之一。 */
export type HjcOrderStatusKey = 'pending' | 'done' | 'cancel' | 'refund'
/** 判定所需的订单字段(只用到这两个,避免依赖完整的 HjcOrder 类型) */
/**
* 取消来源「超时取消」——后端 `HjcCancelSource.TIMEOUT` 的 name。
*
* 两种取消对外完全同形(都是「已取消」),区别只在这个来源,而它必须被展示出来:
* 超时取消是**系统趁用户不在时替他做的**,用户看不到任何自己触发过的动作,
* 而「我什么都没干,订单怎么没了」正是客服必然会接到的那类问询。
*/
export const HJC_CANCEL_SOURCE_TIMEOUT = 'TIMEOUT'
/** 判定所需的订单字段 */
export interface HjcOrderLike {
payStatus?: number | null
orderStatus?: number | null
/** 取消来源:`BUYER`(买家取消)/ `TIMEOUT`(超时取消),仅已取消订单有值 */
cancelSource?: string | null
/**
* 支付时限的到期时刻(ISO 字符串)。**由后端下发**,前端不自己拿创建时刻加时限——
* 两边各算一次的后果是「倒计时归零」与「后端判定超时」在边界上错开。
*/
payDeadline?: string | null
}
export function hjcOrderStatusKey(o?: HjcOrderLike | null): HjcOrderStatusKey {
@@ -39,10 +55,81 @@ const LABELS: Record<HjcOrderStatusKey, string> = {
refund: '已退款'
}
/** 这张单是不是**超时取消**的(而不是买家自己取消的) */
export function hjcIsTimeoutCancelled(o?: HjcOrderLike | null): boolean {
return hjcOrderStatusKey(o) === 'cancel' && o?.cancelSource === HJC_CANCEL_SOURCE_TIMEOUT
}
export function hjcOrderStatusLabel(o?: HjcOrderLike | null): string {
// 超时取消要说清是谁取消的:只说「已取消」,用户和客服都无法自证
if (hjcIsTimeoutCancelled(o)) return '已取消(支付超时)'
return LABELS[hjcOrderStatusKey(o)]
}
/**
* 解析后端下发的时间串。
*
* **后端的时间格式是 `yyyy-MM-dd HH:mm:ss`**(`application.yml` 的 `date-format` 与
* `JacksonConfig` 共同决定:没有 `T`、没有时区)。而 `Date.parse` 对**不带 `T`** 的写法
* 在 **iOS Safari / 微信内置浏览器**上返回 `NaN`——那恰恰是本功能最主要的那一端
* (公众号 iOS)。后果是倒计时不显示、「到点锁页面」静默失效,而且不报任何错。
*
* 所以统一在这里把空格换成 `T`:ES 规范的 ISO 无时区写法按**本地时间**解析,
* 与服务端的 `GMT+8` 一致。返回 null 表示**不知道**(解析不出来),
* 调用方必须按「不显示倒计时、不锁页面」处理——拿一个假值去锁死收银台更糟。
*/
export function hjcParseServerTime(raw?: string | null): number | null {
if (!raw) return null
const text = String(raw).trim()
if (!text) return null
const normalized = text.includes('T') ? text : text.replace(' ', 'T')
const ms = Date.parse(normalized)
return Number.isNaN(ms) ? null : ms
}
/** 支付时限到期时刻的毫秒数;后端没下发或解析不出来时返回 `null` */
export function hjcPayDeadlineMs(o?: HjcOrderLike | null): number | null {
return hjcParseServerTime(o?.payDeadline)
}
/** 收银台倒计时剩余毫秒;没有到期时刻时返回 null,已归零时返回 0(不是负数) */
export function hjcPayRemainMs(o?: HjcOrderLike | null, now: number = Date.now()): number | null {
const deadline = hjcPayDeadlineMs(o)
return deadline == null ? null : Math.max(0, deadline - now)
}
/**
* 支付时限是否已到。
*
* **到期时刻未知时返回 false**(不是 true):拿不到 `payDeadline` 就不该把按钮藏起来,
* 那种"因为不知道所以当成过期"的写法会把正常订单的操作入口一并藏掉。
*/
export function hjcIsPayExpired(o?: HjcOrderLike | null, now: number = Date.now()): boolean {
const deadline = hjcPayDeadlineMs(o)
return deadline != null && now >= deadline
}
/**
* 页面用的完整判定:这张单**能操作**(去支付 / 取消)**且支付时限未到**。
*
* 为什么必须共享而不是各页面自己拼:`hjcCanActOnOrder` 只管状态,时限要当前时刻,
* 于是每个页面都会顺手写一遍 `canAct`——两处一分叉,就会出现"列表里不能点、详情里能点"。
* `hjc-h5` 那边导出的是 `canActOn`,两侧是同一套定义(见本文件头注释)。
*/
export function hjcCanActOn(o?: HjcOrderLike | null, now: number = Date.now()): boolean {
return hjcCanActOnOrder(o) && !hjcIsPayExpired(o, now)
}
/** 倒计时文案。超过一小时给 `h:mm:ss`,免得显示成 `90:00` 那样难读 */
export function hjcFormatCountdown(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000))
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
const pad = (n: number) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
}
/** 状态徽标的 Tailwind 配色 */
export function hjcOrderStatusClass(o?: HjcOrderLike | null): string {
switch (hjcOrderStatusKey(o)) {