Files
hjc-web/app/utils/orderStatus.ts
T
weicw-dshsa 0e77c98ead feat(order): 官网「我的订单」列表加状态筛选 tab
加一排 tab(全部/待支付/已完成/已取消)与各状态计数,点一下只看该状态的订单。数据本来就是全量返回(/hjc/order/my 不分页、也没有状态参数),所以在前端筛,切 tab 不重新请求。选中项写进 URL(?status=pending),刷新、分享、浏览器后退都保持;认不出的值回落「全部」而不是筛成空列表。状态口径复用 utils/orderStatus.ts,页面不重写判定。不新增「已退款」tab:标书售卖没有退款(ADR 0005),payStatus=3 只是防御性展示。

Closes .scratch/web-order-status-tabs/issues/01-orders-status-tabs.md
2026-09-30 10:13:23 +08:00

220 lines
9.5 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
}
// ------------------------------------------------------------------ 列表筛选
/**
* 订单列表的筛选键:`all`(不筛)+ 四个状态键。
*
* `all` 刻意**不**并进 `HjcOrderStatusKey`,而是单独一层联合:一旦把 `'all'` 混进状态键,
* `hjcOrderStatusKey()` 的返回值就不再是"这张单真的处于什么状态",每个 `switch`
* 都得补一个永远走不到的 `case 'all'`,漏补时 TS 还会以为已经穷尽。
*/
export type HjcOrderFilterKey = 'all' | HjcOrderStatusKey
/**
* 列表页的筛选 tab(数组顺序即展示顺序)。
*
* **没有「已退款」**:标书售卖没有退款功能,`payStatus === 3` 只是防御性展示
* (工作区根目录 `docs/adr/0005-no-refund-for-tender-sales.md`)。挂一个永远是空的 tab,
* 只会让人追问"我的退款去哪了";真出现退款单时它在「全部」里照样找得到。
*
* 标签必须与 `hjc-h5/src/pages/order/list.vue` 的 tabs 保持一致——同本文件头注释的口径。
*/
export const HJC_ORDER_FILTER_TABS: ReadonlyArray<{ key: HjcOrderFilterKey; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待支付' },
{ key: 'done', label: '已完成' },
{ key: 'cancel', label: '已取消' }
]
/**
* 每个筛选键的空列表文案。
*
* 必须逐键给全(`Record<HjcOrderFilterKey, string>` 会盯着):筛到「已完成」却看到一句
* 「暂无订单」,用户无从判断是"这单没有"还是"我的单全丢了"。`refund` 目前没有对应 tab,
* 但类型里还在,留着文案免得将来加 tab 时漏掉。
*/
export const HJC_ORDER_FILTER_EMPTY_TEXT: Record<HjcOrderFilterKey, string> = {
all: '暂无订单',
pending: '暂无待支付订单',
done: '暂无已完成订单',
cancel: '暂无已取消订单',
refund: '暂无已退款订单'
}
/**
* URL 上的 `status` 查询参数 → 筛选键。
*
* 认不出来的一律回落 `all`,**不是**回落成"空筛选":把非法参数当成一个筛不出东西的条件,
* 用户看到的就是"一张单都没有"且没有任何提示,只会当成丢单。
* 容忍数组(`?status=a&status=b` 取第一个)与缺失。
*/
export function hjcOrderFilterFromQuery(raw: unknown): HjcOrderFilterKey {
const value = Array.isArray(raw) ? raw[0] : raw
const key = typeof value === 'string' ? value : ''
return HJC_ORDER_FILTER_TABS.some((t) => t.key === key) ? (key as HjcOrderFilterKey) : 'all'
}
/** 这张订单是否命中筛选键(`all` 恒命中,其余按状态键判定) */
export function hjcMatchOrderFilter(
o: HjcOrderLike | null | undefined,
key: HjcOrderFilterKey
): boolean {
return key === 'all' || hjcOrderStatusKey(o) === key
}