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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user