Files
hjc-web/app/utils/orderStatus.ts
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

157 lines
6.8 KiB
TypeScript
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.
/**
* 订单状态展示口径(hjc-web)。
*
* **必须与 `hjc-h5/src/utils/orderStatus.js` 保持一致**——两端是不同仓库、不同技术栈
* (Nuxt4 + TS / uni-app + JS),代码无法真正共享,只能靠"两处同一套定义"来对齐。
* 改这里的时候,另一边也要改。
*
* 优先级(从高到低):
* payStatus === 3 → 已退款
* orderStatus === 2 → 已取消
* payStatus === 1 || orderStatus === 1 → 已完成
* 其余 → 待支付
*
* 为什么需要它:hjc-web 此前**完全不读 `orderStatus`**(只看 payStatus),
* 于是订单一旦被置为已取消,卡片仍会显示「待支付」并带上「去支付」按钮。
*/
/** 订单状态归类键。四端(web / h5 / 小程序 / 后台)未来的展示都应落在这四个之一。 */
export type HjcOrderStatusKey = 'pending' | 'done' | 'cancel' | 'refund'
/**
* 取消来源「超时取消」——后端 `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 {
if (!o) return 'pending'
if (o.payStatus === 3) return 'refund'
if (o.orderStatus === 2) return 'cancel'
if (o.payStatus === 1 || o.orderStatus === 1) return 'done'
return 'pending'
}
const LABELS: Record<HjcOrderStatusKey, string> = {
pending: '待支付',
done: '已完成',
cancel: '已取消',
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)) {
case 'done':
return 'bg-green-100 text-green-700'
case 'cancel':
case 'refund':
return 'bg-gray-100 text-gray-500'
default:
return 'bg-amber-100 text-amber-700'
}
}
/**
* 这张订单是否该出现「去支付」「取消订单」。
*
* **不要用 `hjcOrderStatusKey(o) === 'pending'` 当条件**:`payStatus=2`(支付失败)也会落进
* `pending` 这个兜底键。虽然它目前是死状态(全仓 `setPayStatus` 只有 0/1/3 三个写入点),
* 但把"能不能操作"绑在一个展示口径的兜底分支上,是下次改动最容易踩的坑。
*/
export function hjcCanActOnOrder(o?: HjcOrderLike | null): boolean {
if (!o) return false
return o.payStatus === 0 && o.orderStatus !== 2
}