From eacca11768075b3c94b01b0bad92d255bbb13e52 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 9 Oct 2026 15:06:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(claim-skin):=20=E4=B9=B0=E5=AE=B6=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E9=A2=86=E5=8F=96=E9=A1=B5=EF=BC=88=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=BC=8F=20/claim/=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 买家从卡券/订单消息里的链接打开,看到物品图片与信息,填游戏账号即可领取。 - **路径式无 `#`**:新增独立 BrowserRouter 分支(照 `/redeem` 的做法), `isClaimSkinPath` 只认 `/claim/` 两段精确匹配; 打手领取页仍是主站 HashRouter 的 `#/claim/:token`(其 pathname 为 `/`),两者互不影响 - **物品卡片**:商品图(映射不到或加载失败都降级为占位块,不出现破图)、 内部 SKU、数量 ×N、订单号、引流标题(灰色副信息) - **领取表单**:按接口下发的 `claimFields` 动态渲染(游戏账号/角色ID), 提交前二次确认让买家核对账号 - **状态**:待领取展示表单;已领取展示成功态(优先于 tokenStatus); token 失效/过期/关闭/已用分别给中文提示与联系客服引导 - 纯函数抽到 `utils/claim-skin.ts` 并补 20 个单测(路由判定、状态映射、字段校验、格式化) --- apps/frontend/src/App.tsx | 29 ++ .../src/pages/claim-skin/ClaimSkinForm.tsx | 89 ++++++ .../pages/claim-skin/ClaimSkinItemCard.tsx | 76 +++++ .../src/pages/claim-skin/ClaimSkinPage.tsx | 222 ++++++++++++++ apps/frontend/src/services/claim-skin.ts | 17 ++ apps/frontend/src/styles/client.css | 254 ++++++++++++++++ apps/frontend/src/types/claim-skin.ts | 48 +++ apps/frontend/src/utils/claim-skin.ts | 276 ++++++++++++++++++ apps/frontend/tests/claim-skin.test.ts | 203 +++++++++++++ 9 files changed, 1214 insertions(+) create mode 100644 apps/frontend/src/pages/claim-skin/ClaimSkinForm.tsx create mode 100644 apps/frontend/src/pages/claim-skin/ClaimSkinItemCard.tsx create mode 100644 apps/frontend/src/pages/claim-skin/ClaimSkinPage.tsx create mode 100644 apps/frontend/src/services/claim-skin.ts create mode 100644 apps/frontend/src/types/claim-skin.ts create mode 100644 apps/frontend/src/utils/claim-skin.ts create mode 100644 apps/frontend/tests/claim-skin.test.ts diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 72aff456..9f5278f7 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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 } + // 买家皮肤领取页是路径式(`https://<域名>/claim/`,不带 `#`),必须早于站点判断, + // 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。 + // 注意:打手领取页仍是主站 HashRouter 里的 `#/claim/:token`,它的 pathname 是 `/`, + // 不会命中这里(判定见 utils/claim-skin.isClaimSkinPath),两者互不影响。 + if (isClaimSkinPath(window.location.pathname)) { + return + } + // 固定兑换链接是路径式(`https://<域名>/redeem`,不带 `#`),必须早于站点判断, // 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。 if (isRedeemPath(window.location.pathname)) { @@ -158,6 +168,25 @@ function RedeemApplication() { ) } +/** + * 路径式皮肤领取页(`/claim/`)的独立路由树。 + * + * 只在 `isClaimSkinPath` 命中时才会挂载,所以这里的兜底路由正常走不到; + * 真走到也复用同一个页面(token 为空时页面自己展示「链接无效」),不会出现路由空屏。 + */ +function ClaimSkinApplication() { + return ( + + }> + + } /> + } /> + + + + ) +} + function WorkerApplication() { return ( diff --git a/apps/frontend/src/pages/claim-skin/ClaimSkinForm.tsx b/apps/frontend/src/pages/claim-skin/ClaimSkinForm.tsx new file mode 100644 index 00000000..fbedda45 --- /dev/null +++ b/apps/frontend/src/pages/claim-skin/ClaimSkinForm.tsx @@ -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>() + + async function handleFinish(values: Record) { + 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 ( + + + 填写领取信息 + + +
+ {fields.map((field) => ( + + + + ))} + +

请反复核对账号,填错将无法发货且不可修改。

+ + +
+
+ ) +} diff --git a/apps/frontend/src/pages/claim-skin/ClaimSkinItemCard.tsx b/apps/frontend/src/pages/claim-skin/ClaimSkinItemCard.tsx new file mode 100644 index 00000000..d7d4e5b3 --- /dev/null +++ b/apps/frontend/src/pages/claim-skin/ClaimSkinItemCard.tsx @@ -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 ( + +
+ {showImage ? ( + {productName} setImageFailed(true)} + /> + ) : ( +
+ +
+ )} +
+ +
+ {productName} + {quantityText ? {quantityText} : null} +
+ + {amountText || paidAt ? ( +

+ {amountText ? `实付 ${amountText}` : ''} + {amountText && paidAt ? ' · ' : ''} + {paidAt ? `支付时间 ${paidAt}` : ''} +

+ ) : null} + + {orderNo ?

订单号 {orderNo}

: null} + + {/* 平台标题只是引流词,降级为更小的副信息,避免买家误认商品 */} + {productTitle && productTitle !== productName ? ( +

{productTitle}

+ ) : null} +
+ ) +} diff --git a/apps/frontend/src/pages/claim-skin/ClaimSkinPage.tsx b/apps/frontend/src/pages/claim-skin/ClaimSkinPage.tsx new file mode 100644 index 00000000..c6a638a9 --- /dev/null +++ b/apps/frontend/src/pages/claim-skin/ClaimSkinPage.tsx @@ -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/`(路径式,不带 `#`)。 + * + * 只用 `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(null) + const [submitting, setSubmitting] = useState(false) + const [outcome, setOutcome] = useState(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 = ( + + } size="large" /> +

正在读取领取信息...

+
+ ) + } else if (loadError || !detail) { + stateBlock = ( + void loadDetail()} + > + 重新加载 + + ) : null + } + /> + ) + } else if (claimed) { + // 已领取优先于 tokenStatus:领取成功后链接通常会被置为 used, + // 此时重开链接应看到成功态(并展示已填账号),而不是「链接已被使用」的错误页。 + stateBlock = ( + + +
+ 已提交的游戏账号 + {claimedAccount || '—'} +
+ {/* 链接已不可用时补一句客服引导,方便用户改账号/查进度 */} + {!tokenView.available && tokenView.contactTip ? ( +

{tokenView.contactTip}

+ ) : null} +
+ ) + } else if (!tokenView.available) { + // 链接不可用(closed / revoked / expired / used / 未知):只提示,绝不展示表单 + stateBlock = ( + + ) + } else if (statusView.showForm) { + stateBlock = + } else { + stateBlock = ( + + ) + } + + return ( +
+
+
+ + 领取中心 + +

+ 请核对订单物品并填写游戏账号,提交后我们会尽快为你发放奖励。 +

+
+ + {/* 物品卡片在拿到详情后始终展示:成功、失败都能核对是哪一单 */} + {detail ? : null} + + {stateBlock} +
+
+ ) +} + +/** 统一的提示态卡片(读取失败 / 链接不可用 / 状态未知) */ +function ClaimSkinStateCard({ + tone, + title, + description, + contactTip, + action, +}: { + tone: ClaimSkinStatusTone + title: string + description: string + contactTip?: string + action?: ReactNode +}) { + return ( + + + {contactTip ?

{contactTip}

: null} + {action ?
{action}
: null} +
+ ) +} diff --git a/apps/frontend/src/services/claim-skin.ts b/apps/frontend/src/services/claim-skin.ts new file mode 100644 index 00000000..1dc04172 --- /dev/null +++ b/apps/frontend/src/services/claim-skin.ts @@ -0,0 +1,17 @@ +import { apiGet, apiPost } from '@/lib/http' +import type { ClaimSkinDetail, ClaimSkinSubmitResult } from '@/types/claim-skin' + +/** + * 买家侧「皮肤领取页」接口封装(路径式公开链接,无需登录态)。 + * + * token 来自路径 `/claim/`,这里统一 `encodeURIComponent`, + * 避免 token 里出现 `/`、`?`、`#` 等字符时拼出错误路径。 + */ +export function fetchClaimSkinDetail(token: string) { + return apiGet(`/api/v1/claim-skin/${encodeURIComponent(token)}`) +} + +/** 提交游戏账号领取。后端返回统一错误结构时由 http 拦截器抛错。 */ +export function submitClaimSkin(token: string, payload: { gameAccount: string }) { + return apiPost(`/api/v1/claim-skin/${encodeURIComponent(token)}`, payload) +} diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index f953a04f..2f53a728 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -472,6 +472,260 @@ margin-bottom: 0; } +/* ===== 买家皮肤领取页 (Skin Claim Page) ===== + 路径式入口 `/claim/`:按竞品的深色玻璃质感卡片做, + 但主按钮沿用仓库主题色 `--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) { diff --git a/apps/frontend/src/types/claim-skin.ts b/apps/frontend/src/types/claim-skin.ts new file mode 100644 index 00000000..d69384cc --- /dev/null +++ b/apps/frontend/src/types/claim-skin.ts @@ -0,0 +1,48 @@ +/** + * 买家侧「皮肤领取页」(路径式 `/claim/`)的接口类型。 + * + * 字段与后端 `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 +} diff --git a/apps/frontend/src/utils/claim-skin.ts b/apps/frontend/src/utils/claim-skin.ts new file mode 100644 index 00000000..61ecf95e --- /dev/null +++ b/apps/frontend/src/utils/claim-skin.ts @@ -0,0 +1,276 @@ +/** + * 买家侧「皮肤领取页」(路径式 `/claim/`)的纯展示逻辑。 + * + * 与 `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/`(不带 `#`)。 + * + * 只认 `/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/`) */ +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 + 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 | 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 | 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 + } +} diff --git a/apps/frontend/tests/claim-skin.test.ts b/apps/frontend/tests/claim-skin.test.ts new file mode 100644 index 00000000..ba47138f --- /dev/null +++ b/apps/frontend/tests/claim-skin.test.ts @@ -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/,容忍结尾斜杠', () => { + 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/: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), '') +})