From c70c3ff6a5f1ec644c024f33cc6a3e096d191694 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 10 Jul 2026 13:55:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E9=A2=86=E5=8F=96=E9=A1=B5?= =?UTF-8?q?=20ClaimPage=20=E4=B8=BA=E5=A4=9A=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 snapshot、轮询、共享 UI、UID/lewan/feifei 步骤组件物理拆分,主文件只保留页面编排与状态。 --- .../src/pages/claim/ClaimFeifeiSteps.tsx | 110 ++ .../src/pages/claim/ClaimHeaderCard.tsx | 40 + .../src/pages/claim/ClaimLewanSteps.tsx | 328 ++++++ apps/frontend/src/pages/claim/ClaimPage.tsx | 956 +----------------- .../frontend/src/pages/claim/ClaimUidStep.tsx | 54 + apps/frontend/src/pages/claim/claim-poll.ts | 68 ++ .../frontend/src/pages/claim/claim-shared.tsx | 92 ++ .../src/pages/claim/claim-snapshot.ts | 260 +++++ 8 files changed, 968 insertions(+), 940 deletions(-) create mode 100644 apps/frontend/src/pages/claim/ClaimFeifeiSteps.tsx create mode 100644 apps/frontend/src/pages/claim/ClaimHeaderCard.tsx create mode 100644 apps/frontend/src/pages/claim/ClaimLewanSteps.tsx create mode 100644 apps/frontend/src/pages/claim/ClaimUidStep.tsx create mode 100644 apps/frontend/src/pages/claim/claim-poll.ts create mode 100644 apps/frontend/src/pages/claim/claim-shared.tsx create mode 100644 apps/frontend/src/pages/claim/claim-snapshot.ts diff --git a/apps/frontend/src/pages/claim/ClaimFeifeiSteps.tsx b/apps/frontend/src/pages/claim/ClaimFeifeiSteps.tsx new file mode 100644 index 00000000..36edf0e7 --- /dev/null +++ b/apps/frontend/src/pages/claim/ClaimFeifeiSteps.tsx @@ -0,0 +1,110 @@ +import { + CheckCircleOutlined, + ExclamationCircleOutlined, + LinkOutlined, +} from '@ant-design/icons' +import { Alert, Button, Card, Typography } from 'antd' +import type { + ClaimKuaishouFeifeiFlowInfo, + ClaimOrderInfo, + ClaimProductInfo, +} from '@/types/claim' +import type { ClaimSnapshot } from './claim-snapshot' +import { InfoTile, UidEditRow } from './claim-shared' + +export function FeifeiClaimPanel({ + feifei, + expectedUid, + order, + product, + taskLastError, + onOpen, + uidInput, + submittingUid, + onUidChange, + onResubmitUid, +}: { + feifei: ClaimKuaishouFeifeiFlowInfo + expectedUid: string + order: ClaimOrderInfo | null + product: ClaimProductInfo | null + taskLastError?: string + onOpen: () => void + uidInput: string + submittingUid: boolean + onUidChange: (value: string) => void + onResubmitUid: () => void +}) { + const openReady = Boolean(feifei.h5UrlWithUid || feifei.h5.rechargeUrl || feifei.h5.entryUrl) + + return ( + +
+ kuaishou-feifei + 第 2 步:打开领取链接 +

{feifei.rechargeStatusLabel || '待领取'}

+
+ + + +
+ + + + +
+ + + + + + {taskLastError ? ( + + ) : null} +
+ ) +} + +export function FeifeiResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { + return ( + +
+ {snapshot.resultVariant === 'warning' ? ( + + ) : ( + + )} +
+ {snapshot.resultTitle} +

{snapshot.resultDescription}

+
+
+ +
+ + + + +
+
+ ) +} diff --git a/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx b/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx new file mode 100644 index 00000000..638f500a --- /dev/null +++ b/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx @@ -0,0 +1,40 @@ +import { Card } from 'antd' +import type { ClaimOrderInfo, ClaimProductInfo } from '@/types/claim' +import { ClaimProductItems } from './claim-shared' + +export function ClaimHeaderCard({ + order, + product, + currentStep, + progressText, + expectedUid, +}: { + order: ClaimOrderInfo | null + product: ClaimProductInfo | null + currentStep: number + progressText: string + expectedUid: string +}) { + return ( + +
+ 商品领取 +

{product?.title || '商品领取'}

+

订单号:{order?.platformOrderId || '-'}

+ {expectedUid ?

游戏 UID:{expectedUid}

: null} + +
+ +
+
+ {[1, 2, 3, 4].map((step) => ( + = step ? 'claim-step-dot active' : 'claim-step-dot'}> + {step} + + ))} +
+ {progressText} +
+
+ ) +} diff --git a/apps/frontend/src/pages/claim/ClaimLewanSteps.tsx b/apps/frontend/src/pages/claim/ClaimLewanSteps.tsx new file mode 100644 index 00000000..12517356 --- /dev/null +++ b/apps/frontend/src/pages/claim/ClaimLewanSteps.tsx @@ -0,0 +1,328 @@ +import { + CheckCircleOutlined, + ExclamationCircleOutlined, + LinkOutlined, + LoadingOutlined, + ReloadOutlined, + SwapOutlined, +} from '@ant-design/icons' +import { Alert, Button, Card, Space, Tooltip, Typography } from 'antd' +import type { ClaimProductInfo } from '@/types/claim' +import { formatAdminDateTime } from '@/utils/admin-time' +import type { ClaimSnapshot } from './claim-snapshot' +import { ClaimProductItems, InfoRow, InfoTile, UidEditRow } from './claim-shared' + +export function KuaishouCloudClaimSteps({ + snapshot, + qrCodeDataUrl, + refreshingRole, + confirmingRole, + rebindingRole, + redeeming, + onOpenBindUrl, + onRefreshRole, + onRebindRole, + onConfirmRole, + onConfirmRedeem, + uidInput, + submittingUid, + onUidChange, + onResubmitUid, +}: { + snapshot: ClaimSnapshot + qrCodeDataUrl: string + refreshingRole: boolean + confirmingRole: boolean + rebindingRole: boolean + redeeming: boolean + onOpenBindUrl: (useCurrentPage?: boolean) => void + onRefreshRole: () => void + onRebindRole: () => void + onConfirmRole: () => void + onConfirmRedeem: () => void + uidInput: string + submittingUid: boolean + onUidChange: (value: string) => void + onResubmitUid: () => void +}) { + if (!snapshot.flow) { + return null + } + + if (snapshot.currentStep === 3) { + return ( + + ) + } + + return ( + + ) +} + +function ClaimBindingStep({ + snapshot, + qrCodeDataUrl, + refreshingRole, + confirmingRole, + rebindingRole, + redeeming, + onOpenBindUrl, + onRefreshRole, + onRebindRole, + onConfirmRole, + onConfirmRedeem, + uidInput, + submittingUid, + onUidChange, + onResubmitUid, +}: { + snapshot: ClaimSnapshot + qrCodeDataUrl: string + refreshingRole: boolean + confirmingRole: boolean + rebindingRole: boolean + redeeming: boolean + onOpenBindUrl: (useCurrentPage?: boolean) => void + onRefreshRole: () => void + onRebindRole: () => void + onConfirmRole: () => void + onConfirmRedeem: () => void + uidInput: string + submittingUid: boolean + onUidChange: (value: string) => void + onResubmitUid: () => void +}) { + const flow = snapshot.flow + if (!flow) { + return null + } + + return ( + + 第 2 步:绑定角色 +

+ 请使用与 UID {snapshot.expectedUid || '-'} 对应的角色扫码绑定。 +

+ + + + {snapshot.isBindingPrepared && qrCodeDataUrl ? ( +
+
+
Q区用Q扫
+ 绑定二维码 +
V区用V扫
+

长按保存相册进行扫码绑定

+
+ + +
+ ) : ( + } + message="系统正在准备绑定资源,请稍后自动刷新。" + /> + )} + +
+ + + + +
+ + {!snapshot.isUidMatched && snapshot.roleId ? ( + + ) : null} + + + + + {snapshot.isUidMatched ? ( + + ) : ( + + + + + + )} + + +
+ 最近刷新:{formatAdminDateTime(flow.role.refreshedAt)} + 链接有效期:{formatAdminDateTime(flow.binding.bindExpiresAt)} +
+
+ ) +} + +function ClaimConfirmStep({ + roleName, + roleId, + expectedUid, + product, + redeeming, + rebindingRole, + onConfirmRedeem, + onRebindRole, +}: { + roleName: string + roleId: string + expectedUid: string + product: ClaimProductInfo | null + redeeming: boolean + rebindingRole: boolean + onConfirmRedeem: () => void + onRebindRole: () => void +}) { + return ( + + 第 3 步:确认兑换信息 +

请再次确认 UID 和商品信息,确认无误后再继续兑换。

+ +
+ + + + + +
+ + + + + + + + + +
+ ) +} + +export function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { + const flow = snapshot.flow + + return ( + +
+ {snapshot.resultVariant === 'warning' ? ( + + ) : ( + + )} +
+ {snapshot.resultTitle} +

{snapshot.resultDescription}

+
+
+ +
+ + + + + + +
+ + + + +
+ ) +} diff --git a/apps/frontend/src/pages/claim/ClaimPage.tsx b/apps/frontend/src/pages/claim/ClaimPage.tsx index 5a2abaf6..9cb45500 100644 --- a/apps/frontend/src/pages/claim/ClaimPage.tsx +++ b/apps/frontend/src/pages/claim/ClaimPage.tsx @@ -1,34 +1,9 @@ -import { - CheckCircleOutlined, - ExclamationCircleOutlined, - LinkOutlined, - LoadingOutlined, - ReloadOutlined, - SwapOutlined, -} from '@ant-design/icons' -import { - Alert, - Button, - Card, - Empty, - Input, - Space, - Spin, - Tooltip, - Typography, -} from 'antd' +import { ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons' +import { Card, Empty, Spin } from 'antd' import QRCode from 'qrcode' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'react-router' -import { - TASK_STATUS, - hasKuaishouCloudRedeemResultStatus, - isClaimInactiveTaskStatus, - isKuaishouCloudCompletedStatus, - isKuaishouCloudRoleConfirmedStatus, - normalizeTaskStatus, -} from '@/domain/task-status' import { isFeedbackDismissed, showConfirm, showError, showSuccess } from '@/lib/feedback' import { confirmKuaishouCloudClaimRole, @@ -37,25 +12,14 @@ import { redeemKuaishouCloudClaim, submitClaimUid, } from '@/services/claim' -import type { - ClaimDetailData, - ClaimKuaishouCloudFlowInfo, - ClaimKuaishouFeifeiFlowInfo, - ClaimOrderInfo, - ClaimProductInfo, -} from '@/types/claim' -import { formatAdminDateTime } from '@/utils/admin-time' +import type { ClaimDetailData } from '@/types/claim' -const BINDING_PREPARE_POLL_MS = 5_000 -const ROLE_FAST_POLL_MS = 10_000 -const ROLE_SLOW_POLL_MS = 30_000 -const ROLE_IDLE_POLL_MS = 60_000 -const ROLE_FAST_WINDOW_MS = 3 * 60_000 -const ROLE_IDLE_WINDOW_MS = 10 * 60_000 -const FEIFEI_POLL_MS = 15_000 - -type ResultVariant = 'success' | 'warning' | 'info' -type ClaimSnapshot = ReturnType +import { FeifeiClaimPanel, FeifeiResultStep } from './ClaimFeifeiSteps' +import { ClaimHeaderCard } from './ClaimHeaderCard' +import { ClaimResultStep, KuaishouCloudClaimSteps } from './ClaimLewanSteps' +import { ClaimUidStep } from './ClaimUidStep' +import { resolveNextPollDelayMs, resolveRolePollDelayMs } from './claim-poll' +import { createClaimSnapshot, type ClaimSnapshot } from './claim-snapshot' export default function ClaimPage() { const { token: routeToken } = useParams<{ token: string }>() @@ -73,8 +37,7 @@ export default function ClaimPage() { const [qrCodeDataUrl, setQrCodeDataUrl] = useState('') const pollTimerRef = useRef(0) - const rolePollBaselineAtRef = useRef(0) - const rolePollBaselineKeyRef = useRef('') + const rolePollBaselineRef = useRef({ roleKey: '', at: 0 }) const snapshot = useMemo( () => createClaimSnapshot(detail, { rebindingRole }), @@ -155,60 +118,14 @@ export default function ClaimPage() { [applyDetail, stopPolling, token], ) - const resolveRolePollDelay = useCallback((nextSnapshot: ClaimSnapshot) => { - const nextRoleKey = [nextSnapshot.roleName, nextSnapshot.roleId].join('::') - const now = Date.now() - - if (nextRoleKey !== rolePollBaselineKeyRef.current) { - rolePollBaselineKeyRef.current = nextRoleKey - rolePollBaselineAtRef.current = now - } - - if (!rolePollBaselineAtRef.current) { - rolePollBaselineAtRef.current = now - } - - const elapsedMs = now - rolePollBaselineAtRef.current - if (elapsedMs < ROLE_FAST_WINDOW_MS) { - return ROLE_FAST_POLL_MS - } - - if (elapsedMs < ROLE_IDLE_WINDOW_MS) { - return ROLE_SLOW_POLL_MS - } - - return ROLE_IDLE_POLL_MS + const resolveNextPollDelay = useCallback((nextSnapshot: ClaimSnapshot) => { + const rolePoll = resolveRolePollDelayMs(nextSnapshot, rolePollBaselineRef.current) + rolePollBaselineRef.current = rolePoll.baseline + return resolveNextPollDelayMs(nextSnapshot, rolePoll.delay) }, []) - const resolveNextPollDelay = useCallback( - (nextSnapshot: ClaimSnapshot) => { - if (nextSnapshot.hasRedeemResult || isClaimInactiveTaskStatus(nextSnapshot.task?.status)) { - return 0 - } - - if (!nextSnapshot.hasExpectedUid) { - return 0 - } - - if (nextSnapshot.isFeifeiFlow) { - return FEIFEI_POLL_MS - } - - if (nextSnapshot.currentStep === 2) { - if (!nextSnapshot.isBindingPrepared) { - return BINDING_PREPARE_POLL_MS - } - return resolveRolePollDelay(nextSnapshot) - } - - return 0 - }, - [resolveRolePollDelay], - ) - useEffect(() => { - rolePollBaselineAtRef.current = 0 - rolePollBaselineKeyRef.current = '' + rolePollBaselineRef.current = { roleKey: '', at: 0 } setDetail(null) setQrCodeDataUrl('') setUidInput('') @@ -262,7 +179,7 @@ export default function ClaimPage() { const nextSnapshot = createClaimSnapshot(nextDetail, { rebindingRole }) if (nextSnapshot.isUidMatched) { - showSuccess('角色信息已匹配,可确认下一步') + showSuccess('角色信息已匹配,可确认兑换') } else if (nextSnapshot.roleId) { showError( `当前绑定角色 ID(${nextSnapshot.roleId})与填写 UID(${nextSnapshot.expectedUid || '-'})不一致`, @@ -432,9 +349,6 @@ export default function ClaimPage() { product={snapshot.product} taskLastError={snapshot.task?.lastError} onOpen={() => openFeifeiUrl(false)} - onEditUid={() => { - /* stay on step2; allow re-submit via UID card if needed */ - }} uidInput={uidInput} submittingUid={submittingUid} onUidChange={setUidInput} @@ -466,841 +380,3 @@ export default function ClaimPage() { ) } - -function createClaimSnapshot( - detail: ClaimDetailData | null, - options: { rebindingRole?: boolean } = {}, -) { - const flow = detail?.kuaishouCloudFulfillment || null - const feifei = detail?.kuaishouFeifei || null - const order = detail?.order || null - const orderItem = detail?.orderItem || null - const product = detail?.product || null - const task = detail?.task || null - const claimIdentity = detail?.claimIdentity || null - - const expectedUid = String(claimIdentity?.expectedUid || '').trim() - const hasExpectedUid = Boolean(expectedUid || claimIdentity?.ready) - const roleName = flow?.role.name || flow?.binding.roleName || '' - const roleId = flow?.role.rid || flow?.binding.roleId || '' - const isFeifeiFlow = detail?.flowType === 'kuaishou_feifei' - const isBindUrlExpired = isDateExpired(flow?.binding.bindExpiresAt || '') - const isBindingPrepared = - flow?.binding.prepareStatus === 'ready' && - Boolean(String(flow?.binding.bindUrl || '').trim()) && - !isBindUrlExpired - const isBindingPreparing = flow?.binding.prepareStatus === 'pending' - const isRoleReady = Boolean(roleName || roleId) - const isUidMatched = - Boolean(expectedUid) && Boolean(roleId) && normalizeUid(expectedUid) === normalizeUid(roleId) - const confirmRoleDisabledReason = resolveConfirmRoleDisabledReason({ - isBindingPrepared, - isRoleReady, - isUidMatched, - expectedUid, - roleId, - rebindingRole: Boolean(options.rebindingRole), - }) - const isRoleConfirmed = isKuaishouCloudRoleConfirmedStatus(task?.status) - const isDispatched = String(flow?.dispatch.status || '').trim() === 'success' - const normalizedStatus = normalizeTaskStatus(task?.status) - const isRedeemFailed = - normalizedStatus === TASK_STATUS.MANUAL_REVIEW || - normalizedStatus === TASK_STATUS.FAILED || - String(flow?.dispatch.status || '').trim() === 'failed' || - (isFeifeiFlow && [40, 50].includes(Number(feifei?.rechargeStatus || 0))) - const isCompleted = - isKuaishouCloudCompletedStatus(task?.status) || - (isFeifeiFlow && Number(feifei?.rechargeStatus || 0) === 30) - const hasRedeemResult = - isDispatched || - hasKuaishouCloudRedeemResultStatus(task?.status) || - (isFeifeiFlow && [30, 40, 50, 60].includes(Number(feifei?.rechargeStatus || 0))) - const currentStep = resolveCurrentStep({ - hasExpectedUid, - hasRedeemResult, - isRoleConfirmed, - isFeifeiFlow, - isUidMatched, - }) - const progressText = resolveProgressText({ - feifei, - isFeifeiFlow, - hasExpectedUid, - expectedUid, - hasRedeemResult, - isRoleConfirmed, - isBindingPrepared, - isUidMatched, - }) - const resultTitle = resolveResultTitle({ isRedeemFailed, isCompleted, isDispatched }) - const resultDescription = resolveResultDescription({ - detail, - flow, - feifei, - task, - isRedeemFailed, - isCompleted, - }) - const resultVariant: ResultVariant = isRedeemFailed - ? 'warning' - : isCompleted || isDispatched - ? 'success' - : 'info' - - return { - flow, - feifei, - order, - orderItem, - product, - task, - claimIdentity, - expectedUid, - hasExpectedUid, - roleName, - roleId, - isFeifeiFlow, - isBindUrlExpired, - isBindingPrepared, - isBindingPreparing, - isRoleReady, - isUidMatched, - confirmRoleDisabledReason, - isRoleConfirmed, - isDispatched, - isRedeemFailed, - isCompleted, - hasRedeemResult, - currentStep, - progressText, - resultTitle, - resultDescription, - resultVariant, - } -} - -function normalizeUid(value: unknown) { - return String(value || '') - .trim() - .replace(/\s+/g, '') -} - -function resolveConfirmRoleDisabledReason(options: { - isBindingPrepared: boolean - isRoleReady: boolean - isUidMatched: boolean - expectedUid: string - roleId: string - rebindingRole: boolean -}) { - if (!options.isBindingPrepared) { - return '绑定二维码还在准备中,请稍后自动刷新' - } - if (!options.isRoleReady) { - return '请先扫码绑定自己的角色,并刷新角色信息' - } - if (!options.isUidMatched) { - return `当前绑定角色 ID(${options.roleId || '-'})与填写 UID(${options.expectedUid || '-'})不一致` - } - if (options.rebindingRole) { - return '正在换绑角色,请稍候' - } - return '' -} - -function resolveCurrentStep(options: { - hasExpectedUid: boolean - hasRedeemResult: boolean - isRoleConfirmed: boolean - isFeifeiFlow: boolean - isUidMatched?: boolean -}) { - if (!options.hasExpectedUid) { - return 1 - } - if (options.hasRedeemResult) { - return 4 - } - // UID 匹配后仍停留在绑定步,提供一键兑换;已 ROLE_CONFIRMED 走确认页 - if (!options.isFeifeiFlow && options.isRoleConfirmed && !options.isUidMatched) { - return 3 - } - if (!options.isFeifeiFlow && options.isRoleConfirmed) { - return 3 - } - return 2 -} - -function resolveProgressText(options: { - feifei: ClaimKuaishouFeifeiFlowInfo | null - isFeifeiFlow: boolean - hasExpectedUid: boolean - expectedUid: string - hasRedeemResult: boolean - isRoleConfirmed: boolean - isBindingPrepared: boolean - isUidMatched: boolean -}) { - if (!options.hasExpectedUid) { - return '请填写游戏 UID' - } - if (options.isFeifeiFlow) { - return options.feifei?.rechargeStatusLabel || `UID ${options.expectedUid},请打开领取链接` - } - if (options.hasRedeemResult) { - return '兑换结果已生成' - } - if (options.isRoleConfirmed) { - return '角色已确认,等待兑换' - } - if (!options.isBindingPrepared) { - return '绑定链接准备中,请稍候' - } - if (options.isUidMatched) { - return 'UID 已匹配,请确认下一步' - } - return '请完成扫码绑定并匹配 UID' -} - -function resolveResultTitle(options: { - isRedeemFailed: boolean - isCompleted: boolean - isDispatched: boolean -}) { - if (options.isRedeemFailed) { - return '兑换遇到问题' - } - if (options.isCompleted) { - return '兑换成功' - } - if (options.isDispatched) { - return '兑换请求已提交' - } - return '结果已记录' -} - -function resolveResultDescription(options: { - detail: ClaimDetailData | null - flow: ClaimKuaishouCloudFlowInfo | null - feifei: ClaimKuaishouFeifeiFlowInfo | null - task: ClaimDetailData['task'] | null - isRedeemFailed: boolean - isCompleted: boolean -}) { - if (options.isRedeemFailed) { - const message = String( - options.task?.lastError || - options.flow?.dispatch.errorMessage || - options.feifei?.rechargeResultMessage || - options.detail?.result?.resultMessage || - '', - ).trim() - return message || '当前兑换流程需要客服处理,后续结果请以后台任务界面为准。' - } - - if (options.isCompleted) { - return '当前兑换流程已经完成。' - } - - return '你的兑换请求已经提交。后续结果会由系统保存在后台任务界面。' -} - -function isDateExpired(value: string | null) { - const normalized = String(value || '').trim() - if (!normalized) { - return false - } - - const expiresTime = Date.parse(normalized) - return Number.isFinite(expiresTime) && expiresTime <= Date.now() -} - -function ClaimHeaderCard({ - order, - product, - currentStep, - progressText, - expectedUid, -}: { - order: ClaimOrderInfo | null - product: ClaimProductInfo | null - currentStep: number - progressText: string - expectedUid: string -}) { - return ( - -
- 商品领取 -

{product?.title || '商品领取'}

-

订单号:{order?.platformOrderId || '-'}

- {expectedUid ?

游戏 UID:{expectedUid}

: null} - -
- -
-
- {[1, 2, 3, 4].map((step) => ( - = step ? 'claim-step-dot active' : 'claim-step-dot'}> - {step} - - ))} -
- {progressText} -
-
- ) -} - -function ClaimUidStep({ - uidInput, - submitting, - onChange, - onSubmit, - product, -}: { - uidInput: string - submitting: boolean - onChange: (value: string) => void - onSubmit: () => void - product: ClaimProductInfo | null -}) { - return ( - - 第 1 步:填写游戏 UID -

- 请填写你要领取的游戏角色 UID。提交后系统会按平台继续绑定或跳转领取。 -

- -
- - -
- - - - - onChange(event.target.value)} - onPressEnter={onSubmit} - /> - - - - -
- ) -} - -function KuaishouCloudClaimSteps({ - snapshot, - qrCodeDataUrl, - refreshingRole, - confirmingRole, - rebindingRole, - redeeming, - onOpenBindUrl, - onRefreshRole, - onRebindRole, - onConfirmRole, - onConfirmRedeem, - uidInput, - submittingUid, - onUidChange, - onResubmitUid, -}: { - snapshot: ClaimSnapshot - qrCodeDataUrl: string - refreshingRole: boolean - confirmingRole: boolean - rebindingRole: boolean - redeeming: boolean - onOpenBindUrl: (useCurrentPage?: boolean) => void - onRefreshRole: () => void - onRebindRole: () => void - onConfirmRole: () => void - onConfirmRedeem: () => void - uidInput: string - submittingUid: boolean - onUidChange: (value: string) => void - onResubmitUid: () => void -}) { - if (!snapshot.flow) { - return null - } - - if (snapshot.currentStep === 3) { - return ( - - ) - } - - return ( - - ) -} - -function ClaimBindingStep({ - snapshot, - qrCodeDataUrl, - refreshingRole, - confirmingRole, - rebindingRole, - redeeming, - onOpenBindUrl, - onRefreshRole, - onRebindRole, - onConfirmRole, - onConfirmRedeem, - uidInput, - submittingUid, - onUidChange, - onResubmitUid, -}: { - snapshot: ClaimSnapshot - qrCodeDataUrl: string - refreshingRole: boolean - confirmingRole: boolean - rebindingRole: boolean - redeeming: boolean - onOpenBindUrl: (useCurrentPage?: boolean) => void - onRefreshRole: () => void - onRebindRole: () => void - onConfirmRole: () => void - onConfirmRedeem: () => void - uidInput: string - submittingUid: boolean - onUidChange: (value: string) => void - onResubmitUid: () => void -}) { - const flow = snapshot.flow - if (!flow) { - return null - } - - return ( - - 第 2 步:绑定角色 -

- 请使用与 UID {snapshot.expectedUid || '-'} 对应的角色扫码绑定。 -

- - - - {snapshot.isBindingPrepared && qrCodeDataUrl ? ( -
-
-
Q区用Q扫
- 绑定二维码 -
V区用V扫
-

长按保存相册进行扫码绑定

-
- - -
- ) : ( - } - message="系统正在准备绑定资源,请稍后自动刷新。" - /> - )} - -
- - - - -
- - {!snapshot.isUidMatched && snapshot.roleId ? ( - - ) : null} - - - - - {snapshot.isUidMatched ? ( - - ) : ( - - - - - - )} - - -
- 最近刷新:{formatAdminDateTime(flow.role.refreshedAt)} - 链接有效期:{formatAdminDateTime(flow.binding.bindExpiresAt)} -
-
- ) -} - -function ClaimConfirmStep({ - roleName, - roleId, - expectedUid, - product, - redeeming, - rebindingRole, - onConfirmRedeem, - onRebindRole, -}: { - roleName: string - roleId: string - expectedUid: string - product: ClaimProductInfo | null - redeeming: boolean - rebindingRole: boolean - onConfirmRedeem: () => void - onRebindRole: () => void -}) { - return ( - - 第 3 步:确认兑换信息 -

请再次确认 UID 和商品信息,确认无误后再继续兑换。

- -
- - - - - -
- - - - - - - - - -
- ) -} - -function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { - const flow = snapshot.flow - - return ( - -
- {snapshot.resultVariant === 'warning' ? ( - - ) : ( - - )} -
- {snapshot.resultTitle} -

{snapshot.resultDescription}

-
-
- -
- - - - - - -
- - - - -
- ) -} - -function FeifeiClaimPanel({ - feifei, - expectedUid, - order, - product, - taskLastError, - onOpen, - uidInput, - submittingUid, - onUidChange, - onResubmitUid, -}: { - feifei: ClaimKuaishouFeifeiFlowInfo - expectedUid: string - order: ClaimOrderInfo | null - product: ClaimProductInfo | null - taskLastError?: string - onOpen: () => void - onEditUid?: () => void - uidInput: string - submittingUid: boolean - onUidChange: (value: string) => void - onResubmitUid: () => void -}) { - const openReady = Boolean(feifei.h5UrlWithUid || feifei.h5.rechargeUrl || feifei.h5.entryUrl) - - return ( - -
- kuaishou-feifei - 第 2 步:打开领取链接 -

{feifei.rechargeStatusLabel || '待领取'}

-
- - - -
- - - - -
- - - - - - {taskLastError ? : null} -
- ) -} - -function FeifeiResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { - return ( - -
- {snapshot.resultVariant === 'warning' ? ( - - ) : ( - - )} -
- {snapshot.resultTitle} -

{snapshot.resultDescription}

-
-
- -
- - - - -
-
- ) -} - -function UidEditRow({ - uidInput, - submitting, - onChange, - onSubmit, -}: { - uidInput: string - submitting: boolean - onChange: (value: string) => void - onSubmit: () => void -}) { - return ( - - onChange(event.target.value)} - onPressEnter={onSubmit} - /> - - - ) -} - -function ClaimProductItems({ - product, - compact = false, -}: { - product: ClaimProductInfo | null - compact?: boolean -}) { - const items = getProductItems(product) - const hasMultipleItems = Boolean(product?.isBundle || items.length > 1) - - if (!items.length) { - return null - } - - return ( -
-
- {hasMultipleItems ? '包含商品' : '商品明细'} - {hasMultipleItems ? {items.length} 项 : null} -
- -
- {items.map((item) => ( -
- {item.name} - x{item.quantity} -
- ))} -
-
- ) -} - -function getProductItems(product: ClaimProductInfo | null) { - const items = Array.isArray(product?.items) ? product.items : [] - return items - .map((item) => ({ - key: item.cloudSkuId || item.name, - name: String(item.name || '').trim(), - quantity: Math.max(1, Number(item.quantity || 1) || 1), - })) - .filter((item) => item.name) -} - -function InfoTile({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ) -} - -function InfoRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ) -} diff --git a/apps/frontend/src/pages/claim/ClaimUidStep.tsx b/apps/frontend/src/pages/claim/ClaimUidStep.tsx new file mode 100644 index 00000000..cf2b98fc --- /dev/null +++ b/apps/frontend/src/pages/claim/ClaimUidStep.tsx @@ -0,0 +1,54 @@ +import { Alert, Button, Card, Input, Space, Typography } from 'antd' +import type { ClaimProductInfo } from '@/types/claim' +import { ClaimProductItems, InfoTile } from './claim-shared' + +export function ClaimUidStep({ + uidInput, + submitting, + onChange, + onSubmit, + product, +}: { + uidInput: string + submitting: boolean + onChange: (value: string) => void + onSubmit: () => void + product: ClaimProductInfo | null +}) { + return ( + + 第 1 步:填写游戏 UID +

+ 请填写你要领取的游戏角色 UID。提交后系统会按平台继续绑定或跳转领取。 +

+ +
+ + +
+ + + + + onChange(event.target.value)} + onPressEnter={onSubmit} + /> + + + + +
+ ) +} diff --git a/apps/frontend/src/pages/claim/claim-poll.ts b/apps/frontend/src/pages/claim/claim-poll.ts new file mode 100644 index 00000000..af777deb --- /dev/null +++ b/apps/frontend/src/pages/claim/claim-poll.ts @@ -0,0 +1,68 @@ +import { isClaimInactiveTaskStatus } from '@/domain/task-status' +import type { ClaimSnapshot } from './claim-snapshot' + +export const BINDING_PREPARE_POLL_MS = 5_000 +export const ROLE_FAST_POLL_MS = 10_000 +export const ROLE_SLOW_POLL_MS = 30_000 +export const ROLE_IDLE_POLL_MS = 60_000 +export const ROLE_FAST_WINDOW_MS = 3 * 60_000 +export const ROLE_IDLE_WINDOW_MS = 10 * 60_000 +export const FEIFEI_POLL_MS = 15_000 + +export function resolveRolePollDelayMs( + nextSnapshot: ClaimSnapshot, + baseline: { roleKey: string; at: number }, + now = Date.now(), +) { + const nextRoleKey = [nextSnapshot.roleName, nextSnapshot.roleId].join('::') + let baselineAt = baseline.at + let baselineKey = baseline.roleKey + + if (nextRoleKey !== baselineKey) { + baselineKey = nextRoleKey + baselineAt = now + } + + if (!baselineAt) { + baselineAt = now + } + + const elapsedMs = now - baselineAt + let delay = ROLE_IDLE_POLL_MS + if (elapsedMs < ROLE_FAST_WINDOW_MS) { + delay = ROLE_FAST_POLL_MS + } else if (elapsedMs < ROLE_IDLE_WINDOW_MS) { + delay = ROLE_SLOW_POLL_MS + } + + return { + delay, + baseline: { roleKey: baselineKey, at: baselineAt }, + } +} + +export function resolveNextPollDelayMs( + nextSnapshot: ClaimSnapshot, + rolePollDelayMs: number, +): number { + if (nextSnapshot.hasRedeemResult || isClaimInactiveTaskStatus(nextSnapshot.task?.status)) { + return 0 + } + + if (!nextSnapshot.hasExpectedUid) { + return 0 + } + + if (nextSnapshot.isFeifeiFlow) { + return FEIFEI_POLL_MS + } + + if (nextSnapshot.currentStep === 2) { + if (!nextSnapshot.isBindingPrepared) { + return BINDING_PREPARE_POLL_MS + } + return rolePollDelayMs + } + + return 0 +} diff --git a/apps/frontend/src/pages/claim/claim-shared.tsx b/apps/frontend/src/pages/claim/claim-shared.tsx new file mode 100644 index 00000000..1ba7c3e9 --- /dev/null +++ b/apps/frontend/src/pages/claim/claim-shared.tsx @@ -0,0 +1,92 @@ +import { Button, Input, Space } from 'antd' +import type { ClaimProductInfo } from '@/types/claim' + +export function ClaimProductItems({ + product, + compact = false, +}: { + product: ClaimProductInfo | null + compact?: boolean +}) { + const items = getProductItems(product) + const hasMultipleItems = Boolean(product?.isBundle || items.length > 1) + + if (!items.length) { + return null + } + + return ( +
+
+ {hasMultipleItems ? '包含商品' : '商品明细'} + {hasMultipleItems ? {items.length} 项 : null} +
+ +
+ {items.map((item) => ( +
+ {item.name} + x{item.quantity} +
+ ))} +
+
+ ) +} + +export function UidEditRow({ + uidInput, + submitting, + onChange, + onSubmit, +}: { + uidInput: string + submitting: boolean + onChange: (value: string) => void + onSubmit: () => void +}) { + return ( + + onChange(event.target.value)} + onPressEnter={onSubmit} + /> + + + ) +} + +export function InfoTile({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +export function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function getProductItems(product: ClaimProductInfo | null) { + const items = Array.isArray(product?.items) ? product.items : [] + return items + .map((item) => ({ + key: item.cloudSkuId || item.name, + name: String(item.name || '').trim(), + quantity: Math.max(1, Number(item.quantity || 1) || 1), + })) + .filter((item) => item.name) +} diff --git a/apps/frontend/src/pages/claim/claim-snapshot.ts b/apps/frontend/src/pages/claim/claim-snapshot.ts new file mode 100644 index 00000000..7931ae88 --- /dev/null +++ b/apps/frontend/src/pages/claim/claim-snapshot.ts @@ -0,0 +1,260 @@ +import { + TASK_STATUS, + hasKuaishouCloudRedeemResultStatus, + isKuaishouCloudCompletedStatus, + isKuaishouCloudRoleConfirmedStatus, + normalizeTaskStatus, +} from '@/domain/task-status' +import type { + ClaimDetailData, + ClaimKuaishouCloudFlowInfo, + ClaimKuaishouFeifeiFlowInfo, +} from '@/types/claim' + +export type ResultVariant = 'success' | 'warning' | 'info' +export type ClaimSnapshot = ReturnType + +export function createClaimSnapshot( + detail: ClaimDetailData | null, + options: { rebindingRole?: boolean } = {}, +) { + const flow = detail?.kuaishouCloudFulfillment || null + const feifei = detail?.kuaishouFeifei || null + const order = detail?.order || null + const orderItem = detail?.orderItem || null + const product = detail?.product || null + const task = detail?.task || null + const claimIdentity = detail?.claimIdentity || null + + const expectedUid = String(claimIdentity?.expectedUid || '').trim() + const hasExpectedUid = Boolean(expectedUid || claimIdentity?.ready) + const roleName = flow?.role.name || flow?.binding.roleName || '' + const roleId = flow?.role.rid || flow?.binding.roleId || '' + const isFeifeiFlow = detail?.flowType === 'kuaishou_feifei' + const isBindUrlExpired = isDateExpired(flow?.binding.bindExpiresAt || '') + const isBindingPrepared = + flow?.binding.prepareStatus === 'ready' && + Boolean(String(flow?.binding.bindUrl || '').trim()) && + !isBindUrlExpired + const isBindingPreparing = flow?.binding.prepareStatus === 'pending' + const isRoleReady = Boolean(roleName || roleId) + const isUidMatched = + Boolean(expectedUid) && Boolean(roleId) && normalizeUid(expectedUid) === normalizeUid(roleId) + const confirmRoleDisabledReason = resolveConfirmRoleDisabledReason({ + isBindingPrepared, + isRoleReady, + isUidMatched, + expectedUid, + roleId, + rebindingRole: Boolean(options.rebindingRole), + }) + const isRoleConfirmed = isKuaishouCloudRoleConfirmedStatus(task?.status) + const isDispatched = String(flow?.dispatch.status || '').trim() === 'success' + const normalizedStatus = normalizeTaskStatus(task?.status) + const isRedeemFailed = + normalizedStatus === TASK_STATUS.MANUAL_REVIEW || + normalizedStatus === TASK_STATUS.FAILED || + String(flow?.dispatch.status || '').trim() === 'failed' || + (isFeifeiFlow && [40, 50].includes(Number(feifei?.rechargeStatus || 0))) + const isCompleted = + isKuaishouCloudCompletedStatus(task?.status) || + (isFeifeiFlow && Number(feifei?.rechargeStatus || 0) === 30) + const hasRedeemResult = + isDispatched || + hasKuaishouCloudRedeemResultStatus(task?.status) || + (isFeifeiFlow && [30, 40, 50, 60].includes(Number(feifei?.rechargeStatus || 0))) + const currentStep = resolveCurrentStep({ + hasExpectedUid, + hasRedeemResult, + isRoleConfirmed, + isFeifeiFlow, + isUidMatched, + }) + const progressText = resolveProgressText({ + feifei, + isFeifeiFlow, + hasExpectedUid, + expectedUid, + hasRedeemResult, + isRoleConfirmed, + isBindingPrepared, + isUidMatched, + }) + const resultTitle = resolveResultTitle({ isRedeemFailed, isCompleted, isDispatched }) + const resultDescription = resolveResultDescription({ + detail, + flow, + feifei, + task, + isRedeemFailed, + isCompleted, + }) + const resultVariant: ResultVariant = isRedeemFailed + ? 'warning' + : isCompleted || isDispatched + ? 'success' + : 'info' + + return { + flow, + feifei, + order, + orderItem, + product, + task, + claimIdentity, + expectedUid, + hasExpectedUid, + roleName, + roleId, + isFeifeiFlow, + isBindUrlExpired, + isBindingPrepared, + isBindingPreparing, + isRoleReady, + isUidMatched, + confirmRoleDisabledReason, + isRoleConfirmed, + isDispatched, + isRedeemFailed, + isCompleted, + hasRedeemResult, + currentStep, + progressText, + resultTitle, + resultDescription, + resultVariant, + } +} + +export function normalizeUid(value: unknown) { + return String(value || '') + .trim() + .replace(/\s+/g, '') +} + +function resolveConfirmRoleDisabledReason(options: { + isBindingPrepared: boolean + isRoleReady: boolean + isUidMatched: boolean + expectedUid: string + roleId: string + rebindingRole: boolean +}) { + if (!options.isBindingPrepared) { + return '绑定二维码还在准备中,请稍后自动刷新' + } + if (!options.isRoleReady) { + return '请先扫码绑定自己的角色,并刷新角色信息' + } + if (!options.isUidMatched) { + return `当前绑定角色 ID(${options.roleId || '-'})与填写 UID(${options.expectedUid || '-'})不一致` + } + if (options.rebindingRole) { + return '正在换绑角色,请稍候' + } + return '' +} + +function resolveCurrentStep(options: { + hasExpectedUid: boolean + hasRedeemResult: boolean + isRoleConfirmed: boolean + isFeifeiFlow: boolean + isUidMatched?: boolean +}) { + if (!options.hasExpectedUid) { + return 1 + } + if (options.hasRedeemResult) { + return 4 + } + if (!options.isFeifeiFlow && options.isRoleConfirmed) { + return 3 + } + return 2 +} + +function resolveProgressText(options: { + feifei: ClaimKuaishouFeifeiFlowInfo | null + isFeifeiFlow: boolean + hasExpectedUid: boolean + expectedUid: string + hasRedeemResult: boolean + isRoleConfirmed: boolean + isBindingPrepared: boolean + isUidMatched: boolean +}) { + if (!options.hasExpectedUid) { + return '请填写游戏 UID' + } + if (options.isFeifeiFlow) { + return options.feifei?.rechargeStatusLabel || `UID ${options.expectedUid},请打开领取链接` + } + if (options.hasRedeemResult) { + return '兑换结果已生成' + } + if (options.isRoleConfirmed) { + return '角色已确认,等待兑换' + } + if (!options.isBindingPrepared) { + return '绑定链接准备中,请稍候' + } + if (options.isUidMatched) { + return 'UID 已匹配,请确认兑换' + } + return '请完成扫码绑定并匹配 UID' +} + +function resolveResultTitle(options: { + isRedeemFailed: boolean + isCompleted: boolean + isDispatched: boolean +}) { + if (options.isRedeemFailed) { + return '兑换遇到问题' + } + if (options.isCompleted) { + return '兑换成功' + } + if (options.isDispatched) { + return '兑换请求已提交' + } + return '结果已记录' +} + +function resolveResultDescription(options: { + detail: ClaimDetailData | null + flow: ClaimKuaishouCloudFlowInfo | null + feifei: ClaimKuaishouFeifeiFlowInfo | null + task: ClaimDetailData['task'] | null + isRedeemFailed: boolean + isCompleted: boolean +}) { + if (options.isRedeemFailed) { + const message = String( + options.task?.lastError || + options.flow?.dispatch.errorMessage || + options.feifei?.rechargeResultMessage || + options.detail?.result?.resultMessage || + '', + ).trim() + return message || '当前兑换流程需要客服处理,后续结果请以后台任务界面为准。' + } + + if (options.isCompleted) { + return '当前兑换流程已经完成。' + } + + return '你的兑换请求已经提交。后续结果会由系统保存在后台任务界面。' +} + +function isDateExpired(value: string | null) { + const normalized = String(value || '').trim() + if (!normalized) { + return false + } + + const expiresTime = Date.parse(normalized) + return Number.isFinite(expiresTime) && expiresTime <= Date.now() +}