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
@@ -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>
)
}