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