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
+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)) {