feat(claim-skin): 买家皮肤领取页(路径式 /claim/<token>)

买家从卡券/订单消息里的链接打开,看到物品图片与信息,填游戏账号即可领取。

- **路径式无 `#`**:新增独立 BrowserRouter 分支(照 `/redeem` 的做法),
  `isClaimSkinPath` 只认 `/claim/<token>` 两段精确匹配;
  打手领取页仍是主站 HashRouter 的 `#/claim/:token`(其 pathname 为 `/`),两者互不影响
- **物品卡片**:商品图(映射不到或加载失败都降级为占位块,不出现破图)、
  内部 SKU、数量 ×N、订单号、引流标题(灰色副信息)
- **领取表单**:按接口下发的 `claimFields` 动态渲染(游戏账号/角色ID),
  提交前二次确认让买家核对账号
- **状态**:待领取展示表单;已领取展示成功态(优先于 tokenStatus);
  token 失效/过期/关闭/已用分别给中文提示与联系客服引导
- 纯函数抽到 `utils/claim-skin.ts` 并补 20 个单测(路由判定、状态映射、字段校验、格式化)
This commit is contained in:
yml2213
2026-10-09 15:06:56 +08:00
parent b70741daec
commit eacca11768
9 changed files with 1214 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
/**
* 买家侧「皮肤领取页」(路径式 `/claim/<token>`)的纯展示逻辑。
*
* 与 `utils/redeem.ts` 同理:把路径判定、金额/时间格式化、动态字段校验、
* 状态 → 文案的映射集中在这里,页面只负责渲染。
* 刻意不依赖 React / antd,也不使用 `@/` 别名,
* 方便 `node --experimental-strip-types --test` 直接跑单测。
*/
import type {
ClaimSkinField,
ClaimSkinOrderStatus,
ClaimSkinTokenStatus,
} from '../types/claim-skin'
export type { ClaimSkinField, ClaimSkinOrderStatus, ClaimSkinTokenStatus }
/** 状态色调:与 antd Result / Alert 的语义色一致 */
export type ClaimSkinStatusTone = 'success' | 'warning' | 'error' | 'info'
/** 非可用链接统一追加的「联系客服」引导 */
export const CLAIM_SKIN_CONTACT_TIP = '如需协助,请通过订单页面联系客服处理。'
/** claimFields 缺失或为空时的兜底字段(与后端默认字段保持一致) */
export const DEFAULT_CLAIM_SKIN_FIELD: ClaimSkinField = {
key: 'gameAccount',
label: '游戏账号 / 角色ID',
placeholder: '请输入你的游戏账号(角色ID)',
required: true,
}
/**
* 路径式领取链接:`https://<域名>/claim/<token>`(不带 `#`)。
*
* 只认 `/claim/<一段 token>`:
* - `/claim/abc` 命中,结尾多余斜杠容忍(`/claim/abc/`);
* - `/claim`、`/claim/`、`/claim/a/b`、`/redeem`、`/` 都不命中;
* - 打手领取页是 hash 路由 `#/claim/:token`,那里的 `pathname` 是 `/`,
* 所以本函数不会命中它,两个页面互不影响。
*
* 返回**解码后**的 token(页面服务端请求前会再编码一次);不命中时返回空串。
*/
export function resolveClaimSkinToken(pathname: unknown): string {
const normalized = String(pathname ?? '').trim()
if (!normalized.startsWith('/')) return ''
const segments = normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
if (segments.length !== 2 || segments[0] !== 'claim') return ''
return decodeTokenSegment(segments[1])
}
/** 是否为路径式皮肤领取页(`/claim/<token>`) */
export function isClaimSkinPath(pathname: unknown): boolean {
return resolveClaimSkinToken(pathname) !== ''
}
/**
* 分 → 元展示(与后台 `formatMoney` / `redeem` 同款格式)。
* 空值 / 非法值按 0 处理:`100 → ¥1.00`。
*/
export function formatClaimSkinAmount(fen: unknown): string {
return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}`
}
/**
* 时间 → `YYYY-MM-DD HH:mm`;空值或非法时间返回空串(调用方自行显示占位符)。
* 后端下发 `2026-10-09 13:18:20`(非严格 ISO),`Date` 在浏览器与 Node 下均可解析。
*/
export function formatClaimSkinTime(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}`
}
/** 数量展示:`1 → ×1`;缺失 / 非法 / 非正数返回空串(调用方直接不展示) */
export function formatClaimSkinQuantity(quantity: unknown): string {
const value = Number(quantity)
if (!Number.isFinite(value) || value < 1) return ''
return `×${Math.floor(value)}`
}
export type ClaimSkinTokenStatusView = {
tone: ClaimSkinStatusTone
/** 是否可用:只有 active 才展示领取表单 */
available: boolean
title: string
description: string
/** 非可用状态的「联系客服」引导,可用状态为空串 */
contactTip: string
}
/**
* 领取链接状态 → 页面提示文案。
*
* 后端以后新增 tokenStatus 时不能崩:未知状态一律回退成中性提示,
* 且**不展示表单**(宁可不发,也不能在链接状态不明时收货)。
*/
export function resolveClaimSkinTokenStatusView(tokenStatus: unknown): ClaimSkinTokenStatusView {
switch (String(tokenStatus ?? '').trim()) {
case 'active':
return {
tone: 'success',
available: true,
title: '可以领取',
description: '请核对下方物品信息,填写游戏账号后提交领取。',
contactTip: '',
}
case 'used':
return {
tone: 'info',
available: false,
title: '该领取链接已被使用',
description: '同一个领取链接只能提交一次,无需重复操作。',
contactTip: CLAIM_SKIN_CONTACT_TIP,
}
case 'expired':
return {
tone: 'warning',
available: false,
title: '领取链接已过期',
description: '链接已超过有效期,无法继续提交领取信息。',
contactTip: CLAIM_SKIN_CONTACT_TIP,
}
case 'revoked':
return {
tone: 'error',
available: false,
title: '领取链接已失效',
description: '该链接已被作废,无法继续使用。',
contactTip: CLAIM_SKIN_CONTACT_TIP,
}
case 'closed':
return {
tone: 'warning',
available: false,
title: '领取通道已关闭',
description: '该商品的领取通道已经关闭。',
contactTip: CLAIM_SKIN_CONTACT_TIP,
}
default:
return {
tone: 'info',
available: false,
title: '领取链接不可用',
description: '链接状态异常,暂时无法提交领取信息。',
contactTip: CLAIM_SKIN_CONTACT_TIP,
}
}
}
export type ClaimSkinStatusView = {
tone: ClaimSkinStatusTone
title: string
description: string
/** 是否展示领取表单 */
showForm: boolean
}
/** 领取单状态 → 页面文案。未知状态不展示表单,引导联系客服。 */
export function resolveClaimSkinStatusView(status: unknown): ClaimSkinStatusView {
switch (String(status ?? '').trim()) {
case 'pending_claim':
return {
tone: 'info',
title: '待领取',
description: '请填写下方信息,提交后我们会尽快为你发放奖励。',
showForm: true,
}
case 'submitted':
return {
tone: 'success',
title: '已提交领取',
description: '我们已收到你的领取信息,将尽快为你发放奖励。',
showForm: false,
}
default:
return {
tone: 'warning',
title: '领取状态未知',
description: '暂时无法确认该笔订单的领取状态。',
showForm: false,
}
}
}
/**
* 归一化后端下发的动态字段:
* 过滤掉缺 key 的脏数据,补齐 label / placeholder / required 默认值;
* 后端没给字段时兜底成「游戏账号 / 角色ID」一个必填项,保证用户仍能提交。
*/
export function resolveClaimSkinFields(fields: unknown): ClaimSkinField[] {
const list = Array.isArray(fields) ? fields : []
const normalized = list
.map((item) => {
const record = (item || {}) as Partial<ClaimSkinField>
const key = String(record.key ?? '').trim()
const label = String(record.label ?? '').trim() || key
return {
key,
label,
placeholder: String(record.placeholder ?? '').trim() || `请输入${label}`,
// 后端未显式声明 false 时按必填处理:空值提交没有意义
required: record.required !== false,
}
})
.filter((field) => Boolean(field.key))
return normalized.length > 0 ? normalized : [DEFAULT_CLAIM_SKIN_FIELD]
}
/** 单字段校验:返回错误文案,通过时返回空串 */
export function validateClaimSkinField(field: ClaimSkinField, value: unknown): string {
const label = String(field?.label ?? '').trim() || String(field?.key ?? '').trim() || '该项'
const normalized = String(value ?? '').trim()
if (field?.required && !normalized) {
return `请输入${label}`
}
return ''
}
/** 整表校验:返回**第一条**错误文案,全部通过时返回空串 */
export function validateClaimSkinFields(
fields: ClaimSkinField[],
values: Record<string, unknown> | null | undefined,
): string {
const list = Array.isArray(fields) ? fields : []
const source = values || {}
for (const field of list) {
const errorMessage = validateClaimSkinField(field, source[String(field?.key ?? '')])
if (errorMessage) return errorMessage
}
return ''
}
/**
* 取提交用的游戏账号:
* 优先取 key 为 `gameAccount` 的字段,否则退化为**第一个字段**的值
* (契约里 POST body 固定只有 `gameAccount` 一个字段)。
*/
export function resolveClaimSkinGameAccount(
fields: ClaimSkinField[],
values: Record<string, unknown> | null | undefined,
): string {
const list = Array.isArray(fields) ? fields : []
const source = values || {}
const hasGameAccount = list.some((field) => String(field?.key ?? '') === 'gameAccount')
const targetKey = hasGameAccount ? 'gameAccount' : String(list[0]?.key ?? '')
if (!targetKey) return ''
return String(source[targetKey] ?? '').trim()
}
function decodeTokenSegment(segment: string): string {
try {
return decodeURIComponent(segment)
} catch {
// 非法百分号编码:退回原始片段,交给后端判定有效性
return segment
}
}