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,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), '')
|
||||
})
|
||||
Reference in New Issue
Block a user