- 路径式入口 `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、无 @ 别名),可直接跑单测
83 lines
3.0 KiB
TypeScript
83 lines
3.0 KiB
TypeScript
/**
|
||
* 固定兑换链接(`/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: '订单状态未知' }
|
||
}
|
||
}
|