0e77c98ead
加一排 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
220 lines
9.5 KiB
TypeScript
220 lines
9.5 KiB
TypeScript
/**
|
||
* 订单状态展示口径(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
|
||
}
|