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
+29
View File
@@ -11,6 +11,7 @@ import {
} from 'react-router'
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
import { isClaimSkinPath } from '@/utils/claim-skin'
import { isRedeemPath } from '@/utils/redeem'
import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site'
import { hasWorkerSession } from '@/utils/worker-auth'
@@ -35,6 +36,7 @@ const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage'))
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
const ClaimSkinPage = lazy(() => import('@/pages/claim-skin/ClaimSkinPage'))
const CollectPage = lazy(() => import('@/pages/collect/CollectPage'))
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
@@ -90,6 +92,14 @@ export default function App() {
return <WorkerSiteRedirect path={legacyWorkerPath} />
}
// 买家皮肤领取页是路径式(`https://<域名>/claim/<token>`,不带 `#`),必须早于站点判断,
// 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。
// 注意:打手领取页仍是主站 HashRouter 里的 `#/claim/:token`,它的 pathname 是 `/`,
// 不会命中这里(判定见 utils/claim-skin.isClaimSkinPath),两者互不影响。
if (isClaimSkinPath(window.location.pathname)) {
return <ClaimSkinApplication />
}
// 固定兑换链接是路径式(`https://<域名>/redeem`,不带 `#`),必须早于站点判断,
// 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。
if (isRedeemPath(window.location.pathname)) {
@@ -158,6 +168,25 @@ function RedeemApplication() {
)
}
/**
* 路径式皮肤领取页(`/claim/<token>`)的独立路由树。
*
* 只在 `isClaimSkinPath` 命中时才会挂载,所以这里的兜底路由正常走不到;
* 真走到也复用同一个页面(token 为空时页面自己展示「链接无效」),不会出现路由空屏。
*/
function ClaimSkinApplication() {
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/claim/:token" element={<ClaimSkinPage />} />
<Route path="*" element={<ClaimSkinPage />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
function WorkerApplication() {
return (
<BrowserRouter>
@@ -0,0 +1,89 @@
import { Button, Card, Form, Input, Typography } from 'antd'
import { showConfirm, showError } from '@/lib/feedback'
import type { ClaimSkinField } from '@/types/claim-skin'
import { resolveClaimSkinGameAccount, validateClaimSkinFields } from '@/utils/claim-skin'
type ClaimSkinFormProps = {
/** 已归一化的动态字段(见 `resolveClaimSkinFields`) */
fields: ClaimSkinField[]
submitting: boolean
onSubmit: (gameAccount: string) => void
}
/**
* 领取表单:按 `claimFields` 动态渲染输入项 + 二次确认后提交。
*
* 校验用 `utils/claim-skin` 的纯函数(单一事实来源),所以这里不给
* Form.Item 配 rules,避免两处文案漂移:不通过时弹全局错误提示。
*/
export function ClaimSkinForm({ fields, submitting, onSubmit }: ClaimSkinFormProps) {
const [form] = Form.useForm<Record<string, string>>()
async function handleFinish(values: Record<string, string>) {
const errorMessage = validateClaimSkinFields(fields, values)
if (errorMessage) {
showError(errorMessage)
return
}
const gameAccount = resolveClaimSkinGameAccount(fields, values)
if (!gameAccount) {
showError('请填写游戏账号')
return
}
try {
// 账号提交后无法修改,提交前让用户再核对一次
await showConfirm(
`请核对游戏账号「${gameAccount}」,提交后无法修改。确认无误再领取。`,
'确认领取',
{ okText: '账号无误,确认领取', cancelText: '返回检查' },
)
} catch {
// 用户取消二次确认:保留已填内容,什么都不做
return
}
onSubmit(gameAccount)
}
return (
<Card className="claim-skin-card claim-skin-form-card" variant="borderless">
<Typography.Title level={5} className="claim-skin-form-title">
填写领取信息
</Typography.Title>
<Form form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}>
{fields.map((field) => (
<Form.Item
key={field.key}
name={field.key}
className="claim-skin-field"
label={field.label}
>
<Input
className="claim-skin-input"
size="large"
autoComplete="off"
allowClear
placeholder={field.placeholder || `请输入${field.label}`}
/>
</Form.Item>
))}
<p className="claim-skin-form-hint">请反复核对账号,填错将无法发货且不可修改。</p>
<Button
className="claim-skin-submit"
type="primary"
htmlType="submit"
block
loading={submitting}
>
确认领取
</Button>
</Form>
</Card>
)
}
@@ -0,0 +1,76 @@
import { PictureOutlined } from '@ant-design/icons'
import { Card } from 'antd'
import { useState } from 'react'
import type { ClaimSkinDetail } from '@/types/claim-skin'
import {
formatClaimSkinAmount,
formatClaimSkinQuantity,
formatClaimSkinTime,
} from '@/utils/claim-skin'
/**
* 待领取物品卡片:居中大图 + 商品名 + 数量 + 订单号 + 订单标题。
*
* 图片两种降级都走占位块(不出现破图):
* 1. `imageUrl` 为空串 / 全空格;
* 2. 图片加载失败(onError)。
*/
export function ClaimSkinItemCard({ detail }: { detail: ClaimSkinDetail }) {
const [imageFailed, setImageFailed] = useState(false)
const imageUrl = String(detail?.imageUrl ?? '').trim()
const productName = String(detail?.productName ?? '').trim() || '待领取物品'
const productTitle = String(detail?.productTitle ?? '').trim()
const orderNo = String(detail?.orderNo ?? '').trim()
const quantityText = formatClaimSkinQuantity(detail?.quantity)
const paidAt = formatClaimSkinTime(detail?.paidAt)
const amountText = Number.isFinite(Number(detail?.amountFen))
? formatClaimSkinAmount(detail?.amountFen)
: ''
const showImage = Boolean(imageUrl) && !imageFailed
return (
<Card className="claim-skin-card claim-skin-item-card" variant="borderless">
<div className="claim-skin-item-image-wrap">
{showImage ? (
<img
className="claim-skin-item-image"
src={imageUrl}
alt={productName}
loading="lazy"
onError={() => setImageFailed(true)}
/>
) : (
<div
className="claim-skin-item-image claim-skin-item-image-fallback"
role="img"
aria-label="物品图片待补充"
>
<PictureOutlined />
</div>
)}
</div>
<div className="claim-skin-item-name">
<span>{productName}</span>
{quantityText ? <strong className="claim-skin-item-qty">{quantityText}</strong> : null}
</div>
{amountText || paidAt ? (
<p className="claim-skin-item-meta">
{amountText ? `实付 ${amountText}` : ''}
{amountText && paidAt ? ' · ' : ''}
{paidAt ? `支付时间 ${paidAt}` : ''}
</p>
) : null}
{orderNo ? <p className="claim-skin-item-meta">订单号 {orderNo}</p> : null}
{/* 平台标题只是引流词,降级为更小的副信息,避免买家误认商品 */}
{productTitle && productTitle !== productName ? (
<p className="claim-skin-item-meta claim-skin-item-meta-sub">{productTitle}</p>
) : null}
</Card>
)
}
@@ -0,0 +1,222 @@
import { LoadingOutlined } from '@ant-design/icons'
import { Button, Card, Result, Spin, Typography } from 'antd'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'react-router'
import { showError, showSuccess } from '@/lib/feedback'
import { fetchClaimSkinDetail, submitClaimSkin } from '@/services/claim-skin'
import type { ClaimSkinDetail } from '@/types/claim-skin'
import {
CLAIM_SKIN_CONTACT_TIP,
resolveClaimSkinFields,
resolveClaimSkinStatusView,
resolveClaimSkinTokenStatusView,
type ClaimSkinStatusTone,
} from '@/utils/claim-skin'
import { ClaimSkinForm } from './ClaimSkinForm'
import { ClaimSkinItemCard } from './ClaimSkinItemCard'
type ClaimSkinOutcome = {
status: string
message: string
gameAccount: string
}
/**
* 买家侧「皮肤领取页」:`https://<域名>/claim/<token>`(路径式,不带 `#`)。
*
* 只用 `useState` 驱动、不引 TanStack Query:这是一次性公开链接,
* 数据由首次加载与用户提交触发,没有需要缓存/复用的服务端状态。
* 流程:读取详情 → 按 tokenStatus 决定是否可用 → 按 status 展示表单或成功态 → 提交。
*/
export default function ClaimSkinPage() {
const { token: routeToken } = useParams<{ token: string }>()
const token = String(routeToken || '').trim()
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [detail, setDetail] = useState<ClaimSkinDetail | null>(null)
const [submitting, setSubmitting] = useState(false)
const [outcome, setOutcome] = useState<ClaimSkinOutcome | null>(null)
const loadDetail = useCallback(async () => {
if (!token) {
setDetail(null)
setLoadError('领取链接无效,请检查链接是否完整')
setLoading(false)
return
}
setLoading(true)
setLoadError('')
try {
const response = await fetchClaimSkinDetail(token)
setDetail(response.data ?? null)
if (!response.data) {
setLoadError('领取信息不完整,请稍后重试')
}
} catch (error) {
setDetail(null)
setLoadError(error instanceof Error ? error.message : '读取领取信息失败')
} finally {
setLoading(false)
}
}, [token])
useEffect(() => {
// 切换 token(重新打开链接)时清掉上一单的成功态,避免串号展示
setOutcome(null)
void loadDetail()
}, [loadDetail])
async function handleSubmit(gameAccount: string) {
if (submitting) return
setSubmitting(true)
try {
const response = await submitClaimSkin(token, { gameAccount })
// 后端可能返回非 submitted(例如链接已被占用):按返回状态渲染,不能假设一定成功
setOutcome({
status: String(response.data?.status ?? ''),
message: String(response.data?.message ?? ''),
gameAccount,
})
if (response.data?.status === 'submitted') {
showSuccess(response.data.message || '领取成功')
} else {
showError(response.data?.message || '提交失败,请稍后再试')
}
} catch (error) {
showError(error instanceof Error ? error.message : '提交失败,请稍后再试')
} finally {
setSubmitting(false)
}
}
const fields = resolveClaimSkinFields(detail?.claimFields)
const tokenView = resolveClaimSkinTokenStatusView(detail?.tokenStatus)
const effectiveStatus = outcome?.status === 'submitted' ? 'submitted' : detail?.status
const statusView = resolveClaimSkinStatusView(effectiveStatus)
const claimed = effectiveStatus === 'submitted'
const claimedAccount = String(outcome?.gameAccount || detail?.submittedGameAccount || '').trim()
// 下面的 if/else 链覆盖全部情况,这里不赋初值(也避免 eslint 的 no-useless-assignment)
let stateBlock: ReactNode
if (loading) {
stateBlock = (
<Card className="claim-skin-card claim-skin-state" variant="borderless">
<Spin indicator={<LoadingOutlined spin />} size="large" />
<p className="claim-skin-state-desc">正在读取领取信息...</p>
</Card>
)
} else if (loadError || !detail) {
stateBlock = (
<ClaimSkinStateCard
tone="error"
title={token ? '读取领取信息失败' : '领取链接无效'}
description={loadError || '领取信息不完整,请联系客服处理。'}
contactTip={token ? CLAIM_SKIN_CONTACT_TIP : ''}
action={
token ? (
<Button
className="claim-skin-submit"
type="primary"
block
onClick={() => void loadDetail()}
>
重新加载
</Button>
) : null
}
/>
)
} else if (claimed) {
// 已领取优先于 tokenStatus:领取成功后链接通常会被置为 used,
// 此时重开链接应看到成功态(并展示已填账号),而不是「链接已被使用」的错误页。
stateBlock = (
<Card className="claim-skin-card claim-skin-state" variant="borderless">
<Result
status="success"
title="领取成功"
subTitle={outcome?.message || statusView.description}
/>
<div className="claim-skin-account-line">
<span>已提交的游戏账号</span>
<strong>{claimedAccount || '—'}</strong>
</div>
{/* 链接已不可用时补一句客服引导,方便用户改账号/查进度 */}
{!tokenView.available && tokenView.contactTip ? (
<p className="claim-skin-contact-tip">{tokenView.contactTip}</p>
) : null}
</Card>
)
} else if (!tokenView.available) {
// 链接不可用(closed / revoked / expired / used / 未知):只提示,绝不展示表单
stateBlock = (
<ClaimSkinStateCard
tone={tokenView.tone}
title={tokenView.title}
description={tokenView.description}
contactTip={tokenView.contactTip}
/>
)
} else if (statusView.showForm) {
stateBlock = <ClaimSkinForm fields={fields} submitting={submitting} onSubmit={handleSubmit} />
} else {
stateBlock = (
<ClaimSkinStateCard
tone={statusView.tone}
title={statusView.title}
description={statusView.description}
contactTip={CLAIM_SKIN_CONTACT_TIP}
/>
)
}
return (
<main className="claim-skin-page">
<div className="claim-skin-shell">
<header className="claim-skin-head">
<Typography.Title level={3} className="claim-skin-title">
领取中心
</Typography.Title>
<p className="claim-skin-subtitle">
请核对订单物品并填写游戏账号,提交后我们会尽快为你发放奖励。
</p>
</header>
{/* 物品卡片在拿到详情后始终展示:成功、失败都能核对是哪一单 */}
{detail ? <ClaimSkinItemCard detail={detail} /> : null}
{stateBlock}
</div>
</main>
)
}
/** 统一的提示态卡片(读取失败 / 链接不可用 / 状态未知) */
function ClaimSkinStateCard({
tone,
title,
description,
contactTip,
action,
}: {
tone: ClaimSkinStatusTone
title: string
description: string
contactTip?: string
action?: ReactNode
}) {
return (
<Card className="claim-skin-card claim-skin-state" variant="borderless">
<Result status={tone} title={title} subTitle={description || undefined} />
{contactTip ? <p className="claim-skin-contact-tip">{contactTip}</p> : null}
{action ? <div className="claim-skin-state-action">{action}</div> : null}
</Card>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { apiGet, apiPost } from '@/lib/http'
import type { ClaimSkinDetail, ClaimSkinSubmitResult } from '@/types/claim-skin'
/**
* 买家侧「皮肤领取页」接口封装(路径式公开链接,无需登录态)。
*
* token 来自路径 `/claim/<token>`,这里统一 `encodeURIComponent`,
* 避免 token 里出现 `/`、`?`、`#` 等字符时拼出错误路径。
*/
export function fetchClaimSkinDetail(token: string) {
return apiGet<ClaimSkinDetail>(`/api/v1/claim-skin/${encodeURIComponent(token)}`)
}
/** 提交游戏账号领取。后端返回统一错误结构时由 http 拦截器抛错。 */
export function submitClaimSkin(token: string, payload: { gameAccount: string }) {
return apiPost<ClaimSkinSubmitResult>(`/api/v1/claim-skin/${encodeURIComponent(token)}`, payload)
}
+254
View File
@@ -472,6 +472,260 @@
margin-bottom: 0;
}
/* ===== 买家皮肤领取页 (Skin Claim Page) =====
路径式入口 `/claim/<token>`:按竞品的深色玻璃质感卡片做,
但主按钮沿用仓库主题色 `--theme-primary`(橙色渐变),与 /redeem 的主色保持协调。
所有覆盖都挂在 `.claim-skin-page` 下,保证优先级高于 antd 的运行时样式。 */
.claim-skin-page {
min-height: 100vh;
display: flex;
justify-content: center;
padding: 28px 16px 40px;
background: radial-gradient(120% 70% at 50% 0%, #27334f 0%, #141b2d 52%, #0b1020 100%);
color: #e2e8f0;
}
.claim-skin-shell {
width: min(480px, 100%);
display: flex;
flex-direction: column;
gap: 14px;
}
.claim-skin-head {
text-align: center;
}
.claim-skin-page .claim-skin-title {
margin: 0 !important;
color: #f8fafc;
font-size: 22px;
letter-spacing: 0.5px;
}
.claim-skin-subtitle {
margin: 8px 0 0;
color: rgba(226, 232, 240, 0.72);
font-size: 13px;
line-height: 1.6;
}
.claim-skin-page .claim-skin-card {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 16px;
background: rgba(15, 23, 42, 0.66);
box-shadow: 0 18px 40px rgba(2, 6, 23, 0.45);
backdrop-filter: blur(12px);
}
.claim-skin-card .ant-card-body {
display: flex;
flex-direction: column;
gap: 14px;
padding: 20px 18px;
}
/* --- 物品卡片 --- */
.claim-skin-item-image-wrap {
display: flex;
justify-content: center;
}
.claim-skin-item-image {
width: 160px;
height: 160px;
border-radius: 16px;
object-fit: cover;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.24);
}
.claim-skin-item-image-fallback {
display: flex;
align-items: center;
justify-content: center;
font-size: 46px;
color: rgba(148, 163, 184, 0.55);
border-style: dashed;
}
.claim-skin-item-name {
display: flex;
align-items: baseline;
justify-content: center;
gap: 8px;
color: #f1f5f9;
font-size: 19px;
font-weight: 700;
line-height: 1.4;
text-align: center;
overflow-wrap: anywhere;
}
.claim-skin-item-qty {
flex: 0 0 auto;
color: #ffb27a;
font-size: 16px;
}
.claim-skin-item-meta {
margin: 0;
color: rgba(148, 163, 184, 0.92);
font-size: 12px;
line-height: 1.6;
text-align: center;
overflow-wrap: anywhere;
}
.claim-skin-item-meta-sub {
color: rgba(148, 163, 184, 0.62);
font-size: 11px;
}
/* --- 表单卡片 --- */
.claim-skin-page .claim-skin-form-title {
margin: 0 !important;
color: #f8fafc;
font-size: 16px;
}
.claim-skin-form-card .ant-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.claim-skin-form-card .ant-form-item {
margin-bottom: 0;
}
.claim-skin-form-card .ant-form-item-label > label {
color: rgba(226, 232, 240, 0.86);
font-size: 14px;
font-weight: 600;
}
.claim-skin-field .ant-input {
background: rgba(8, 13, 26, 0.6);
border-color: rgba(148, 163, 184, 0.3);
color: #f1f5f9;
}
.claim-skin-field .ant-input::placeholder {
color: rgba(148, 163, 184, 0.62);
}
.claim-skin-field .ant-input-affix-wrapper {
background: rgba(8, 13, 26, 0.6);
border-color: rgba(148, 163, 184, 0.3);
}
.claim-skin-field .ant-input-affix-wrapper > input.ant-input {
background: transparent;
}
.claim-skin-field .ant-input-affix-wrapper .anticon {
color: rgba(148, 163, 184, 0.72);
}
.claim-skin-form-hint {
margin: 0;
color: rgba(253, 186, 116, 0.9);
font-size: 12px;
line-height: 1.6;
}
.claim-skin-page .claim-skin-submit.ant-btn {
height: 48px;
border: none;
border-radius: 14px;
font-size: 16px;
font-weight: 700;
background: linear-gradient(135deg, #ff8a3d 0%, var(--theme-primary) 100%);
box-shadow: 0 12px 24px var(--theme-primary-shadow);
}
.claim-skin-page .claim-skin-submit.ant-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #ff9a55 0%, var(--theme-primary-hover) 100%);
}
.claim-skin-page .claim-skin-submit.ant-btn:disabled {
opacity: 0.6;
}
/* --- 提示 / 成功态 --- */
.claim-skin-state .ant-result {
padding: 8px 0 0;
}
.claim-skin-state .ant-result-title {
color: #f8fafc;
font-size: 19px;
}
.claim-skin-state .ant-result-subtitle {
color: rgba(226, 232, 240, 0.72);
font-size: 13px;
line-height: 1.6;
}
.claim-skin-state-desc {
margin: 0;
color: rgba(226, 232, 240, 0.72);
font-size: 13px;
line-height: 1.6;
text-align: center;
}
.claim-skin-contact-tip {
margin: 0;
color: rgba(253, 186, 116, 0.9);
font-size: 12px;
line-height: 1.6;
text-align: center;
}
.claim-skin-account-line {
display: grid;
gap: 4px;
padding: 12px;
border-radius: 12px;
background: rgba(8, 13, 26, 0.55);
border: 1px solid rgba(148, 163, 184, 0.22);
text-align: center;
}
.claim-skin-account-line span {
color: rgba(148, 163, 184, 0.9);
font-size: 12px;
}
.claim-skin-account-line strong {
color: #f8fafc;
font-size: 18px;
overflow-wrap: anywhere;
}
@media (max-width: 480px) {
.claim-skin-page {
padding: 18px 12px 32px;
}
.claim-skin-card .ant-card-body {
gap: 12px;
padding: 16px 14px;
}
.claim-skin-item-image {
width: 140px;
height: 140px;
}
}
/* ===== 买家页面响应式断点 ===== */
@media (max-width: 720px) {
+48
View File
@@ -0,0 +1,48 @@
/**
* 买家侧「皮肤领取页」(路径式 `/claim/<token>`)的接口类型。
*
* 字段与后端 `GET/POST /api/v1/claim-skin/:token` 契约严格对齐,
* 与打手领取页 `types/claim.ts` 是**两套不同的接口**,不要混用。
*/
/** 领取链接(token)状态:非 active 时页面只提示,不展示表单 */
export type ClaimSkinTokenStatus =
| 'active'
| 'closed'
| 'revoked'
| 'expired'
| 'used'
| (string & {})
/** 领取单状态:待领取(显示表单)/ 已领取(成功态) */
export type ClaimSkinOrderStatus = 'pending_claim' | 'submitted' | (string & {})
/** 后端下发的动态表单字段定义(通常是「游戏账号 / 角色ID」一个输入项) */
export interface ClaimSkinField {
key: string
label: string
placeholder?: string
required?: boolean
}
/** `GET /api/v1/claim-skin/:token` 的 data */
export interface ClaimSkinDetail {
tokenStatus: ClaimSkinTokenStatus
flowType: string
orderNo: string
productName: string
productTitle: string
imageUrl: string
quantity: number
amountFen: number
paidAt: string
status: ClaimSkinOrderStatus
claimFields: ClaimSkinField[]
submittedGameAccount: string
}
/** `POST /api/v1/claim-skin/:token` 的 data */
export interface ClaimSkinSubmitResult {
status: ClaimSkinOrderStatus
message: string
}
+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
}
}
+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), '')
})