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
+203
View File
@@ -0,0 +1,203 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
DEFAULT_CLAIM_SKIN_FIELD,
formatClaimSkinAmount,
formatClaimSkinQuantity,
formatClaimSkinTime,
isClaimSkinPath,
resolveClaimSkinFields,
resolveClaimSkinGameAccount,
resolveClaimSkinStatusView,
resolveClaimSkinToken,
resolveClaimSkinTokenStatusView,
validateClaimSkinField,
validateClaimSkinFields,
} from '../src/utils/claim-skin.ts'
import type { ClaimSkinTokenStatus } from '../src/types/claim-skin.ts'
/** 故意构造后端未来可能新增的状态,验证未知状态不会崩 */
const FUTURE_TOKEN_STATUS = 'reviewing' as unknown as ClaimSkinTokenStatus
test('isClaimSkinPath:只认路径式 /claim/<token>,容忍结尾斜杠', () => {
assert.equal(isClaimSkinPath('/claim/abc123'), true)
assert.equal(isClaimSkinPath('/claim/abc123/'), true)
assert.equal(isClaimSkinPath('/claim/abc123//'), true)
})
test('isClaimSkinPath:/claim、/claim/、多段路径、其它路径都不算', () => {
assert.equal(isClaimSkinPath('/claim'), false)
assert.equal(isClaimSkinPath('/claim/'), false)
assert.equal(isClaimSkinPath('/claim/a/b'), false)
assert.equal(isClaimSkinPath('/redeem'), false)
assert.equal(isClaimSkinPath('/admin/claim/abc'), false)
assert.equal(isClaimSkinPath('/'), false)
assert.equal(isClaimSkinPath(''), false)
assert.equal(isClaimSkinPath(null), false)
assert.equal(isClaimSkinPath(undefined), false)
})
test('isClaimSkinPath:打手页是 hash 路由,pathname 为 / 或 hash 串时不命中', () => {
// 打手链接形如 https://<域名>/#/claim/<token>:pathname 是 '/',hash 才是路由
assert.equal(isClaimSkinPath('/'), false)
assert.equal(isClaimSkinPath('#/claim/abc123'), false)
assert.equal(isClaimSkinPath('/claim/abc123#/claim/other'), true)
})
test('resolveClaimSkinToken:取出并解码 token 片段', () => {
assert.equal(resolveClaimSkinToken('/claim/abc123'), 'abc123')
assert.equal(resolveClaimSkinToken('/claim/abc123/?x=1'), 'abc123')
assert.equal(resolveClaimSkinToken('/claim/a%2Fb'), 'a/b')
assert.equal(resolveClaimSkinToken('/claim/%E4%B8%AD%E6%96%87'), '中文')
// 非法百分号编码退回原始片段,交给后端判定
assert.equal(resolveClaimSkinToken('/claim/100%'), '100%')
assert.equal(resolveClaimSkinToken('/redeem'), '')
})
test('formatClaimSkinAmount:分转元保留两位小数', () => {
assert.equal(formatClaimSkinAmount(100), '¥1.00')
assert.equal(formatClaimSkinAmount(0), '¥0.00')
assert.equal(formatClaimSkinAmount(5), '¥0.05')
assert.equal(formatClaimSkinAmount(1990), '¥19.90')
assert.equal(formatClaimSkinAmount(undefined), '¥0.00')
assert.equal(formatClaimSkinAmount('abc'), '¥0.00')
})
test('formatClaimSkinTime:空值或非法时间返回空串,合法时间为 YYYY-MM-DD HH:mm', () => {
assert.equal(formatClaimSkinTime(''), '')
assert.equal(formatClaimSkinTime(null), '')
assert.equal(formatClaimSkinTime(' '), '')
assert.equal(formatClaimSkinTime('不是时间'), '')
assert.equal(formatClaimSkinTime('2026-10-09 13:18:20'), '2026-10-09 13:18')
assert.match(formatClaimSkinTime('2026-09-17T10:00:00'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
})
test('formatClaimSkinQuantity:正数展示 ×N,非法 / 非正数返回空串', () => {
assert.equal(formatClaimSkinQuantity(1), '×1')
assert.equal(formatClaimSkinQuantity(2), '×2')
assert.equal(formatClaimSkinQuantity('3'), '×3')
assert.equal(formatClaimSkinQuantity(2.9), '×2')
assert.equal(formatClaimSkinQuantity(0), '')
assert.equal(formatClaimSkinQuantity(-1), '')
assert.equal(formatClaimSkinQuantity('abc'), '')
assert.equal(formatClaimSkinQuantity(null), '')
})
test('resolveClaimSkinTokenStatusView:只有 active 可用且不带客服引导', () => {
const active = resolveClaimSkinTokenStatusView('active')
assert.equal(active.available, true)
assert.equal(active.tone, 'success')
assert.equal(active.contactTip, '')
assert.ok(active.title)
assert.ok(active.description)
})
test('resolveClaimSkinTokenStatusView:closed / revoked / expired / used 都不可用且引导联系客服', () => {
for (const status of ['closed', 'revoked', 'expired', 'used']) {
const view = resolveClaimSkinTokenStatusView(status)
assert.equal(view.available, false, `${status} 不应可用`)
assert.notEqual(view.tone, 'success')
assert.ok(view.title, `${status} 需要标题`)
assert.ok(view.description, `${status} 需要说明`)
assert.ok(view.contactTip, `${status} 需要联系客服引导`)
}
assert.equal(resolveClaimSkinTokenStatusView('used').tone, 'info')
assert.equal(resolveClaimSkinTokenStatusView('revoked').tone, 'error')
})
test('resolveClaimSkinTokenStatusView:未知状态回退为不可用的中性提示', () => {
const view = resolveClaimSkinTokenStatusView(FUTURE_TOKEN_STATUS)
assert.equal(view.available, false)
assert.equal(view.tone, 'info')
assert.equal(view.title, '领取链接不可用')
assert.ok(view.contactTip)
})
test('resolveClaimSkinStatusView:pending_claim 展示表单,submitted 展示成功态', () => {
const pending = resolveClaimSkinStatusView('pending_claim')
assert.equal(pending.showForm, true)
assert.equal(pending.tone, 'info')
const submitted = resolveClaimSkinStatusView('submitted')
assert.equal(submitted.showForm, false)
assert.equal(submitted.tone, 'success')
})
test('resolveClaimSkinStatusView:未知状态不展示表单', () => {
const view = resolveClaimSkinStatusView('reviewing')
assert.equal(view.showForm, false)
assert.equal(view.tone, 'warning')
assert.equal(view.title, '领取状态未知')
})
test('resolveClaimSkinFields:透传后端字段并补齐默认值', () => {
assert.deepEqual(
resolveClaimSkinFields([
{
key: 'gameAccount',
label: '游戏账号 / 角色ID',
placeholder: '请输入你的游戏账号(角色ID)',
required: true,
},
]),
[
{
key: 'gameAccount',
label: '游戏账号 / 角色ID',
placeholder: '请输入你的游戏账号(角色ID)',
required: true,
},
],
)
})
test('resolveClaimSkinFields:缺失 / 空数组 / 脏数据都能得到可用字段', () => {
assert.deepEqual(resolveClaimSkinFields([]), [DEFAULT_CLAIM_SKIN_FIELD])
assert.deepEqual(resolveClaimSkinFields(null), [DEFAULT_CLAIM_SKIN_FIELD])
assert.deepEqual(resolveClaimSkinFields(undefined), [DEFAULT_CLAIM_SKIN_FIELD])
const normalized = resolveClaimSkinFields([
{ key: ' ' },
{ key: 'uid', label: '', required: false },
])
assert.deepEqual(normalized, [
{ key: 'uid', label: 'uid', placeholder: '请输入uid', required: false },
])
})
test('validateClaimSkinField:必填空值报错,填好或选填时通过', () => {
const field = { key: 'gameAccount', label: '游戏账号 / 角色ID', required: true }
assert.equal(validateClaimSkinField(field, ''), '请输入游戏账号 / 角色ID')
assert.equal(validateClaimSkinField(field, ' '), '请输入游戏账号 / 角色ID')
assert.equal(validateClaimSkinField(field, undefined), '请输入游戏账号 / 角色ID')
assert.equal(validateClaimSkinField(field, '123456'), '')
assert.equal(validateClaimSkinField({ key: 'note', label: '备注', required: false }, ''), '')
})
test('validateClaimSkinFields:返回第一条错误,全部通过时为空串', () => {
const fields = [
{ key: 'gameAccount', label: '游戏账号 / 角色ID', required: true },
{ key: 'server', label: '区服', required: true },
]
assert.equal(validateClaimSkinFields(fields, {}), '请输入游戏账号 / 角色ID')
assert.equal(validateClaimSkinFields(fields, { gameAccount: '123456' }), '请输入区服')
assert.equal(validateClaimSkinFields(fields, { gameAccount: '123456', server: '亚服' }), '')
})
test('resolveClaimSkinGameAccount:优先取 gameAccount,否则退化为第一个字段', () => {
const fields = [
{ key: 'server', label: '区服' },
{ key: 'gameAccount', label: '游戏账号 / 角色ID' },
]
assert.equal(
resolveClaimSkinGameAccount(fields, { server: '亚服', gameAccount: ' 123456 ' }),
'123456',
)
assert.equal(resolveClaimSkinGameAccount([{ key: 'uid', label: 'UID' }], { uid: ' 987 ' }), '987')
assert.equal(resolveClaimSkinGameAccount([], { gameAccount: '123456' }), '')
assert.equal(resolveClaimSkinGameAccount(fields, null), '')
})