/** * 订单状态展示口径(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 = { 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` 会盯着):筛到「已完成」却看到一句 * 「暂无订单」,用户无从判断是"这单没有"还是"我的单全丢了"。`refund` 目前没有对应 tab, * 但类型里还在,留着文案免得将来加 tab 时漏掉。 */ export const HJC_ORDER_FILTER_EMPTY_TEXT: Record = { 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 }