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、无 @ 别名),可直接跑单测
This commit is contained in:
yml2213
2026-10-09 12:01:35 +08:00
parent 080480f03b
commit 8c33ff62ec
7 changed files with 410 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
/**
* 固定兑换链接(`/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: '订单状态未知' }
}
}