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