Files
order_site/apps/frontend/src/utils/redeem.ts
T
yml2213 8c33ff62ec feat(redeem): 固定兑换链接 /redeem(输入订单号 → 状态分流 → 兑换)
- 路径式入口 `https://<域名>/redeem`(不带 #):主站是 HashRouter,
  故在 App.tsx 里早于 isWorkerSite() 插入独立 BrowserRouter 分支,
  主站与打手站路由均不受影响;Caddy 已有 try_files 兜底,无需改部署
- 页面按 7 种状态渲染:not_found / unpaid / cancelled / refunded /
  pending_certificate / redeemable / verified
  - not_found 保留用户输入的订单号,不清空
  - pending_certificate 只给显式「刷新」,不做自动轮询(公开限流接口,避免空转)
  - verified 只展示后端已掩码的券码,绝不渲染明文 certNo
  - confirm 不假设成功:一律按返回的 status 重渲染
- 纯函数抽到 utils/redeem.ts(无 React/antd、无 @ 别名),可直接跑单测
2026-10-09 12:01:35 +08:00

83 lines
3.0 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.
/**
* 固定兑换链接(`/redeem`)的纯展示逻辑。
*
* 这个页面是「无 token 公开链接」:用户只输订单号,系统按订单状态决定下一步。
* 把路径判定、金额格式化、状态 → 文案/色调集中在这里,页面只负责渲染。
* 刻意写成纯函数:不依赖 React / antd,也不使用 `@/` 别名,
* 方便 `node --experimental-strip-types --test` 直接跑单测。
*/
import type { RedeemOrderStatus } from '../types/redeem'
export type { RedeemOrderStatus }
/** 状态色调:与 antd Result / Alert 的语义色一致 */
export type RedeemStatusTone = 'success' | 'warning' | 'error' | 'info'
export type RedeemStatusView = {
tone: RedeemStatusTone
/** 页面主标题(中性文案,不出现平台名) */
title: string
}
/**
* 固定兑换链接是**路径式**(`https://<域名>/redeem`,不带 `#`),
* 判断时只认 `/redeem`(容忍一个或多个结尾斜杠),`/redeemx`、`/` 都不算。
*/
export function isRedeemPath(pathname: unknown): boolean {
const normalized = String(pathname ?? '').trim()
if (!normalized) return false
return normalized.replace(/\/+$/, '') === '/redeem'
}
/**
* 分 → 元展示(与后台 `formatMoney` 同款格式)。
* 空值 / 非法值按 0 处理:`1990 → ¥19.90`、`0 → ¥0.00`。
*/
export function formatRedeemAmount(fen: unknown): string {
return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}`
}
/**
* 时间 → `YYYY-MM-DD HH:mm`;空值或非法时间返回空串(调用方自行显示占位符)。
*/
export function formatRedeemTime(value: unknown): string {
const normalized = String(value ?? '').trim()
if (!normalized) return ''
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) return ''
const pad = (input: number) => String(input).padStart(2, '0')
const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
const timePart = `${pad(date.getHours())}:${pad(date.getMinutes())}`
return `${datePart} ${timePart}`
}
/**
* 订单状态 → 页面标题与色调。
*
* 后端以后新增状态时不能崩:未知状态一律回退成中性提示。
* 注意 `pending_certificate` 是「稍后再试」而不是「失败」,所以用 info 而不是 error。
*/
export function resolveRedeemStatusView(status: RedeemOrderStatus): RedeemStatusView {
switch (status) {
case 'not_found':
return { tone: 'warning', title: '未找到订单' }
case 'unpaid':
return { tone: 'warning', title: '订单未支付' }
case 'cancelled':
return { tone: 'error', title: '订单已取消' }
case 'refunded':
return { tone: 'error', title: '订单已退款' }
case 'pending_certificate':
return { tone: 'info', title: '兑换券准备中' }
case 'redeemable':
return { tone: 'success', title: '可以兑换' }
case 'verified':
return { tone: 'success', title: '已兑换' }
default:
return { tone: 'info', title: '订单状态未知' }
}
}