diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 9f5278f7..1e99b661 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -11,7 +11,7 @@ import { } from 'react-router' import { getAdminRole, hasAdminSession } from '@/utils/admin-auth' -import { isClaimSkinPath } from '@/utils/claim-skin' +import { isClaimPath } from '@/utils/claim-path' import { isRedeemPath } from '@/utils/redeem' import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site' import { hasWorkerSession } from '@/utils/worker-auth' @@ -36,7 +36,6 @@ 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')) @@ -92,12 +91,13 @@ export default function App() { return } - // 买家皮肤领取页是路径式(`https://<域名>/claim/`,不带 `#`),必须早于站点判断, - // 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。 - // 注意:打手领取页仍是主站 HashRouter 里的 `#/claim/:token`,它的 pathname 是 `/`, - // 不会命中这里(判定见 utils/claim-skin.isClaimSkinPath),两者互不影响。 - if (isClaimSkinPath(window.location.pathname)) { - return + // 路径式领取链接(`https://<域名>/claim/`,不带 `#`)复用主站现成的领取页: + // 买家输 UID → 出二维码 → 扫码绑定 → 比对 UID → 发货完成。 + // 必须早于站点判断,用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。 + // 注意:主站打手领取页是 HashRouter 里的 `#/claim/:token`,它的 pathname 是 `/`, + // 不会命中这里(判定见 utils/claim-path.isClaimPath),两个入口互不影响。 + if (isClaimPath(window.location.pathname)) { + return } // 固定兑换链接是路径式(`https://<域名>/redeem`,不带 `#`),必须早于站点判断, @@ -169,18 +169,21 @@ function RedeemApplication() { } /** - * 路径式皮肤领取页(`/claim/`)的独立路由树。 + * 路径式领取页(`/claim/`)的独立路由树。 * - * 只在 `isClaimSkinPath` 命中时才会挂载,所以这里的兜底路由正常走不到; + * 复用主站 HashRouter 里的**同一个** `ClaimPage`:它只依赖 `useParams` 取 token, + * 内部不 navigate、也不读 hash,所以换成 BrowserRouter 承载不需要复制页面。 + * + * 只在 `isClaimPath` 命中时才会挂载,所以这里的兜底路由正常走不到; * 真走到也复用同一个页面(token 为空时页面自己展示「链接无效」),不会出现路由空屏。 */ -function ClaimSkinApplication() { +function ClaimApplication() { return ( }> - } /> - } /> + } /> + } /> diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index c4aba25f..04d5c4aa 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -361,33 +361,6 @@ export default function AdminTaskDetailPage() { ) : null} - {/* 皮肤自动发货:买家在领取页提交的游戏账号是发货依据,单独成卡,避免被领取链接卡片挤掉 */} - {resolvedDetail.claimSubmission ? ( - - - - 买家在领取页填写的游戏账号(发货依据,属买家隐私,请勿外传) - - - - {resolvedDetail.claimSubmission.gameAccount} - - - 复制 - - - {resolvedDetail.claimSubmission.submittedAt ? ( - - 提交时间:{formatAdminDateTime(resolvedDetail.claimSubmission.submittedAt)} - - ) : null} - - - ) : null} - {resolvedDetail.task.executorKey === 'manual_dispatch' || resolvedDetail.operations.canCompleteManualDispatch || resolvedDetail.manualDispatch ? ( @@ -883,9 +856,6 @@ function resolveTaskExecutorDisplay(value: unknown) { if (executorKey === 'kuaishou_feifei') { return { label: '飞飞(已下线)', color: 'default' } } - if (executorKey === 'douyin_mashida_skin') { - return { label: '皮肤自动发货', color: 'magenta' } - } if (executorKey === 'manual_dispatch') { return { label: '人工履约', color: 'orange' } } diff --git a/apps/frontend/src/pages/claim-skin/ClaimSkinForm.tsx b/apps/frontend/src/pages/claim-skin/ClaimSkinForm.tsx deleted file mode 100644 index fbedda45..00000000 --- a/apps/frontend/src/pages/claim-skin/ClaimSkinForm.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index d7d4e5b3..00000000 --- a/apps/frontend/src/pages/claim-skin/ClaimSkinItemCard.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index c6a638a9..00000000 --- a/apps/frontend/src/pages/claim-skin/ClaimSkinPage.tsx +++ /dev/null @@ -1,222 +0,0 @@ -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/pages/claim/ClaimHeaderCard.tsx b/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx index 0e10c80b..a90bf769 100644 --- a/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx +++ b/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx @@ -1,6 +1,6 @@ import { Card } from 'antd' import type { ClaimOrderInfo, ClaimProductInfo } from '@/types/claim' -import { ClaimProductItems } from './claim-shared' +import { ClaimProductImage, ClaimProductItems } from './claim-shared' export function ClaimHeaderCard({ order, @@ -25,6 +25,9 @@ export function ClaimHeaderCard({ + {/* 物品图放在文案块下方:步骤指示器是绝对定位在右上角的,窄屏时不会与它重叠 */} + +
{[1, 2, 3, 4].map((step) => ( diff --git a/apps/frontend/src/pages/claim/claim-shared.tsx b/apps/frontend/src/pages/claim/claim-shared.tsx index c814ab3c..05511cfc 100644 --- a/apps/frontend/src/pages/claim/claim-shared.tsx +++ b/apps/frontend/src/pages/claim/claim-shared.tsx @@ -1,4 +1,6 @@ +import { PictureOutlined } from '@ant-design/icons' import { Button, Input, Space, Tooltip } from 'antd' +import { useState } from 'react' import type { ClaimProductInfo } from '@/types/claim' export function ClaimProductItems({ @@ -34,6 +36,39 @@ export function ClaimProductItems({ ) } +/** + * 待领取物品主图:居中、圆角、大图。 + * + * `imageUrl` 为空串、或图片加载失败时,降级成同尺寸的虚线占位块(不会出现破图)。 + * 记录的是**失败的 src**:接口刷新后换新链接会自动重试,不需要额外重置状态。 + */ +export function ClaimProductImage({ imageUrl, title }: { imageUrl: string; title: string }) { + const src = String(imageUrl || '').trim() + const [failedSrc, setFailedSrc] = useState('') + + if (!src || failedSrc === src) { + return ( +
+
+ +
+
+ ) + } + + return ( +
+ {title setFailedSrc(src)} + /> +
+ ) +} + export function UidEditRow({ uidInput, submitting, diff --git a/apps/frontend/src/services/claim-skin.ts b/apps/frontend/src/services/claim-skin.ts deleted file mode 100644 index 1dc04172..00000000 --- a/apps/frontend/src/services/claim-skin.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 2f53a728..c143170a 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -434,6 +434,31 @@ background: rgba(248, 250, 252, 0.72); } +/* 待领取物品图:居中大图;imageUrl 为空或加载失败时是同一尺寸的虚线占位块(不会出现破图) */ + +.claim-product-image-wrap { + display: flex; + justify-content: center; +} + +.claim-product-image { + width: min(220px, 62%); + aspect-ratio: 1 / 1; + border-radius: 16px; + object-fit: cover; + background: #f1f5f9; + border: 1px solid rgba(148, 163, 184, 0.24); +} + +.claim-product-image-fallback { + display: flex; + align-items: center; + justify-content: center; + font-size: 46px; + color: rgba(100, 116, 139, 0.55); + border-style: dashed; +} + /* ===== 收集页 (Collect Page) ===== */ .collect-page { @@ -472,260 +497,6 @@ 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/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index 87f33a46..485d0a41 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -101,11 +101,6 @@ export interface AdminTaskDetail { expiredAt: string claimUrl: string } - /** 皮肤自动发货:买家在领取页提交的游戏账号(无权限时后端下发 null) */ - claimSubmission: null | { - gameAccount: string - submittedAt: string - } artifacts: Record screenshotUrl: string review: { diff --git a/apps/frontend/src/types/claim-skin.ts b/apps/frontend/src/types/claim-skin.ts deleted file mode 100644 index d69384cc..00000000 --- a/apps/frontend/src/types/claim-skin.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 买家侧「皮肤领取页」(路径式 `/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/types/claim.ts b/apps/frontend/src/types/claim.ts index 8ea056eb..bc5d42d8 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -50,6 +50,8 @@ export interface ClaimProductInfo { skuCode: string quantity: number isBundle: boolean + /** 待领取物品主图(如 `/images/products/lucky_coin_x2.webp`);空串表示后端未配图,前端展示占位块 */ + imageUrl?: string items: Array<{ cloudSkuId: number name: string diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 66ef25f4..0a4f3507 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -16,8 +16,6 @@ export const adminOrderProviderOptions = [ export const adminOrderExecutorOptions = [ { label: '联盟发货', value: 'affiliate_dash' }, - // 抖音码速达皮肤类:建任务 + 生成领取链接,买家自己填账号领取 - { label: '皮肤自动发货', value: 'douyin_mashida_skin' }, { label: '行业凭证', value: 'kuaishou-industry' }, { label: '人工履约', value: 'manual_dispatch' }, ] diff --git a/apps/frontend/src/utils/claim-path.ts b/apps/frontend/src/utils/claim-path.ts new file mode 100644 index 00000000..abb4eae5 --- /dev/null +++ b/apps/frontend/src/utils/claim-path.ts @@ -0,0 +1,49 @@ +/** + * 路径式领取链接(`https://<域名>/claim/`,不带 `#`)的路径判定。 + * + * 与 `utils/redeem.ts` 同理:把路径解析抽成纯函数,路由与页面只负责调用。 + * 刻意不依赖 React / antd,也不使用 `@/` 别名, + * 方便 `node --experimental-strip-types --test` 直接跑单测。 + * + * 注意两条领取入口**互不影响**: + * - 路径式(买家分享链接):`https://<域名>/claim/`,`pathname` 为 `/claim/`; + * - hash 式(主站打手领取页):`https://<域名>/#/claim/`,`pathname` 为 `/`, + * 路由由 HashRouter 解析,不会命中本文件的判定。 + * + * 两个入口渲染的是**同一个** `pages/claim/ClaimPage`。 + */ + +/** + * 取路径式领取链接里的 token。 + * + * 只认 `/claim/<一段 token>`: + * - `/claim/abc` 命中,结尾多余斜杠容忍(`/claim/abc/`); + * - `/claim`、`/claim/`、`/claim/a/b`、`/redeem`、`/` 都不命中; + * - 查询串 / hash 片段(如 `/claim/abc?x=1`)会被丢弃后取第一段。 + * + * 返回**解码后**的 token;不命中时返回空串。 + */ +export function resolveClaimToken(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 isClaimPath(pathname: unknown): boolean { + return resolveClaimToken(pathname) !== '' +} + +function decodeTokenSegment(segment: string): string { + try { + return decodeURIComponent(segment) + } catch { + // 非法百分号编码:退回原始片段,交给后端判定有效性 + return segment + } +} diff --git a/apps/frontend/src/utils/claim-skin.ts b/apps/frontend/src/utils/claim-skin.ts deleted file mode 100644 index 61ecf95e..00000000 --- a/apps/frontend/src/utils/claim-skin.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * 买家侧「皮肤领取页」(路径式 `/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-path.test.ts b/apps/frontend/tests/claim-path.test.ts new file mode 100644 index 00000000..5ec2bded --- /dev/null +++ b/apps/frontend/tests/claim-path.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { isClaimPath, resolveClaimToken } from '../src/utils/claim-path.ts' + +test('isClaimPath:只认路径式 /claim/,容忍结尾斜杠', () => { + assert.equal(isClaimPath('/claim/abc123'), true) + assert.equal(isClaimPath('/claim/abc123/'), true) + assert.equal(isClaimPath('/claim/abc123//'), true) +}) + +test('isClaimPath:/claim、/claim/、多段路径、其它路径都不算', () => { + assert.equal(isClaimPath('/claim'), false) + assert.equal(isClaimPath('/claim/'), false) + assert.equal(isClaimPath('/claim/a/b'), false) + assert.equal(isClaimPath('/redeem'), false) + assert.equal(isClaimPath('/admin/claim/abc'), false) + assert.equal(isClaimPath('/'), false) + assert.equal(isClaimPath(''), false) + assert.equal(isClaimPath(null), false) + assert.equal(isClaimPath(undefined), false) +}) + +test('isClaimPath:打手页是 hash 路由,pathname 为 / 或 hash 串时不命中', () => { + // 打手链接形如 https://<域名>/#/claim/:pathname 是 '/',hash 才是路由 + assert.equal(isClaimPath('/'), false) + assert.equal(isClaimPath('#/claim/abc123'), false) + // 路径本身带 token 时,多出来的 hash 片段会被忽略 + assert.equal(isClaimPath('/claim/abc123#/claim/other'), true) +}) + +test('resolveClaimToken:取出并解码 token 片段', () => { + assert.equal(resolveClaimToken('/claim/abc123'), 'abc123') + assert.equal(resolveClaimToken('/claim/abc123/?x=1'), 'abc123') + assert.equal(resolveClaimToken('/claim/a%2Fb'), 'a/b') + assert.equal(resolveClaimToken('/claim/%E4%B8%AD%E6%96%87'), '中文') + // 非法百分号编码退回原始片段,交给后端判定 + assert.equal(resolveClaimToken('/claim/100%'), '100%') + assert.equal(resolveClaimToken('/redeem'), '') +}) diff --git a/apps/frontend/tests/claim-skin.test.ts b/apps/frontend/tests/claim-skin.test.ts deleted file mode 100644 index ba47138f..00000000 --- a/apps/frontend/tests/claim-skin.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -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), '') -})