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:
@@ -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: '订单状态未知' }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user