import { CheckCircleOutlined, ClockCircleOutlined, ExclamationCircleOutlined, LinkOutlined, LoadingOutlined, ReloadOutlined, SendOutlined, SwapOutlined, } from '@ant-design/icons' import { Alert, Button, Card, Collapse, Empty, Image, Input, Space, Spin, Tooltip, Typography, } 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, fetchClaimDetail, rebindKuaishouCloudClaimRole, redeemKuaishouCloudClaim, verifyKuaishouCloudClaimTicket, } from '@/services/claim' import type { ClaimDetailData, ClaimKuaishouCloudFlowInfo, ClaimKuaishouFeifeiFlowInfo, ClaimOrderInfo, ClaimProductInfo, } from '@/types/claim' import { formatAdminDateTime } from '@/utils/admin-time' 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 type ResultVariant = 'success' | 'warning' | 'info' type ClaimSnapshot = ReturnType export default function ClaimPage() { const { token: routeToken } = useParams<{ token: string }>() const token = String(routeToken || '').trim() const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) const [refreshingRole, setRefreshingRole] = useState(false) const [confirmingRole, setConfirmingRole] = useState(false) const [rebindingRole, setRebindingRole] = useState(false) const [redeeming, setRedeeming] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [detail, setDetail] = useState(null) const [ticketCode, setTicketCode] = useState('') const [qrCodeDataUrl, setQrCodeDataUrl] = useState('') const pollTimerRef = useRef(0) const rolePollBaselineAtRef = useRef(0) const rolePollBaselineKeyRef = useRef('') const snapshot = useMemo( () => createClaimSnapshot(detail, { rebindingRole }), [detail, rebindingRole], ) const stopPolling = useCallback(() => { if (pollTimerRef.current) { window.clearTimeout(pollTimerRef.current) pollTimerRef.current = 0 } }, []) const generateQRCode = useCallback(async (url: string) => { if (!url) { setQrCodeDataUrl('') return } try { const dataUrl = await QRCode.toDataURL(url, { width: 280, margin: 2, color: { dark: '#0f172a', light: '#ffffff', }, }) setQrCodeDataUrl(dataUrl) } catch (error) { setQrCodeDataUrl('') console.error('生成二维码失败:', error) } }, []) const applyDetail = useCallback( async (nextDetail: ClaimDetailData) => { setDetail(nextDetail) setTicketCode((current) => current || nextDetail.kuaishouCloudFulfillment?.ticket.code || '') await generateQRCode( String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || '').trim(), ) }, [generateQRCode], ) const loadDetail = useCallback( async (options: { silent?: boolean } = {}) => { if (!token) { setLoading(false) setErrorMessage('领取链接无效,请检查链接是否完整') stopPolling() return null } if (!options.silent) { setLoading(true) } setErrorMessage('') try { const response = await fetchClaimDetail(token) await applyDetail(response.data) return response.data } catch (error) { setErrorMessage(error instanceof Error ? error.message : '读取领取信息失败') stopPolling() return null } finally { if (!options.silent) { setLoading(false) } } }, [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) => { if ( !nextSnapshot.flow || nextSnapshot.hasRedeemResult || isClaimInactiveTaskStatus(nextSnapshot.task?.status) ) { return 0 } if (nextSnapshot.currentStep === 2) { if (!nextSnapshot.isBindingPrepared) { return BINDING_PREPARE_POLL_MS } return resolveRolePollDelay(nextSnapshot) } return 0 }, [resolveRolePollDelay], ) useEffect(() => { rolePollBaselineAtRef.current = 0 rolePollBaselineKeyRef.current = '' setDetail(null) setTicketCode('') setQrCodeDataUrl('') void loadDetail() return () => { stopPolling() } }, [loadDetail, stopPolling]) useEffect(() => { stopPolling() const delay = resolveNextPollDelay(snapshot) if (!delay) { return undefined } pollTimerRef.current = window.setTimeout(() => { pollTimerRef.current = 0 void loadDetail({ silent: true }) }, delay) return () => { stopPolling() } }, [loadDetail, resolveNextPollDelay, snapshot, stopPolling]) async function submitTicket() { const normalizedTicketCode = ticketCode.trim() if (!normalizedTicketCode) { showError('请输入核销码') return } setSubmitting(true) try { const response = await verifyKuaishouCloudClaimTicket(token, { ticketCode: normalizedTicketCode, }) await applyDetail(response.data) showSuccess('核销已完成,系统已开始准备绑定资源') } catch (error) { showError(error instanceof Error ? error.message : '核销码验证失败') } finally { setSubmitting(false) } } async function refreshRole() { setRefreshingRole(true) try { const nextDetail = await loadDetail({ silent: true }) const nextSnapshot = createClaimSnapshot(nextDetail, { rebindingRole }) if (nextSnapshot.isCustomerRoleReady) { showSuccess('角色信息已刷新') } else if (nextSnapshot.isDefaultRole) { showError('当前仍是虚拟机默认角色,请重新绑定自己的角色信息') } else if (nextDetail) { showError( nextSnapshot.flow?.role.errorMessage || nextSnapshot.flow?.role.defaultErrorMessage || '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试', ) } } finally { setRefreshingRole(false) } } async function confirmRole() { setConfirmingRole(true) try { const response = await confirmKuaishouCloudClaimRole(token) await applyDetail(response.data) showSuccess('角色已确认,进入下一步') } catch (error) { showError(error instanceof Error ? error.message : '确认角色失败') } finally { setConfirmingRole(false) } } async function confirmRedeem() { try { await showConfirm('兑换后不可取消,也不可退货。请确认角色和商品信息完全正确。', '确认兑换', { okText: '确认兑换', okButtonProps: { danger: true }, }) } catch (error) { if (!isFeedbackDismissed(error)) { showError('确认弹窗已关闭') } return } setRedeeming(true) try { const response = await redeemKuaishouCloudClaim(token) await applyDetail(response.data) showSuccess('兑换请求已提交') } catch (error) { showError(error instanceof Error ? error.message : '兑换失败') } finally { setRedeeming(false) } } async function rebindRole() { try { await showConfirm( '换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。核销码和领取商品不会改变,但需要重新扫码绑定角色。', '确认换绑角色', { okText: '确认换绑', okButtonProps: { danger: true }, }, ) } catch (error) { if (!isFeedbackDismissed(error)) { showError('确认弹窗已关闭') } return } setRebindingRole(true) try { const response = await rebindKuaishouCloudClaimRole(token) await applyDetail(response.data) showSuccess('新的绑定二维码已生成,请重新绑定角色') } catch (error) { showError(error instanceof Error ? error.message : '换绑角色失败') } finally { setRebindingRole(false) } } function openBindUrl(useCurrentPage = false) { const bindUrl = String(snapshot.flow?.binding.bindUrl || '').trim() if (!bindUrl) { showError('绑定链接还没准备好,请稍后刷新') return } if (useCurrentPage) { window.location.assign(bindUrl) return } window.open(bindUrl, '_blank', 'noopener,noreferrer') } function openFeifeiUrl(useCurrentPage = false) { const url = String( snapshot.feifei?.h5.rechargeUrl || snapshot.feifei?.h5.entryUrl || '', ).trim() if (!url) { showError('领取链接还没准备好,请稍后刷新') return } if (useCurrentPage) { window.location.assign(url) return } window.open(url, '_blank', 'noopener,noreferrer') } return (
{loading ? ( } size="large" />

正在读取当前领取进度...

) : errorMessage ? (

{errorMessage}

) : snapshot.isFeifeiFlow && snapshot.feifei ? ( openFeifeiUrl(false)} /> ) : detail && snapshot.flow ? ( ) : ( )}
) } 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 roleName = flow?.role.name || flow?.binding.roleName || '' const roleId = flow?.role.rid || flow?.binding.roleId || '' const isFeifeiFlow = detail?.flowType === 'kuaishou_feifei' const isTicketVerified = flow?.ticket.status === 'verified' 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 canEnterBindingStep = isTicketVerified const isRoleReady = Boolean(roleName || roleId) const hasDefaultRoleSnapshot = Boolean(flow?.role.defaultName || flow?.role.defaultRid) const isDefaultRole = flow?.role.isDefaultRole === true const isCustomerRoleReady = isRoleReady && hasDefaultRoleSnapshot && !isDefaultRole const confirmRoleDisabledReason = resolveConfirmRoleDisabledReason({ flow, isBindingPrepared, hasDefaultRoleSnapshot, isRoleReady, isDefaultRole, 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' const isCompleted = isKuaishouCloudCompletedStatus(task?.status) const hasRedeemResult = isDispatched || hasKuaishouCloudRedeemResultStatus(task?.status) const canSubmitTicket = !isClaimInactiveTaskStatus(task?.status) const currentStep = resolveCurrentStep({ isFeifeiFlow, isCompleted, hasRedeemResult, isRoleConfirmed, canEnterBindingStep, }) const progressText = resolveProgressText({ feifei, isFeifeiFlow, hasRedeemResult, isRoleConfirmed, isTicketVerified, isBindingPrepared, }) const resultTitle = resolveResultTitle({ isRedeemFailed, isCompleted, isDispatched }) const resultDescription = resolveResultDescription({ detail, flow, task, isRedeemFailed, isCompleted }) const resultVariant: ResultVariant = isRedeemFailed ? 'warning' : isCompleted || isDispatched ? 'success' : 'info' return { flow, feifei, order, orderItem, product, task, roleName, roleId, isFeifeiFlow, isTicketVerified, isBindUrlExpired, isBindingPrepared, isBindingPreparing, canEnterBindingStep, isRoleReady, hasDefaultRoleSnapshot, isDefaultRole, isCustomerRoleReady, confirmRoleDisabledReason, isRoleConfirmed, isDispatched, isRedeemFailed, isCompleted, hasRedeemResult, canSubmitTicket, currentStep, progressText, resultTitle, resultDescription, resultVariant, } } function resolveConfirmRoleDisabledReason(options: { flow: ClaimKuaishouCloudFlowInfo | null isBindingPrepared: boolean hasDefaultRoleSnapshot: boolean isRoleReady: boolean isDefaultRole: boolean rebindingRole: boolean }) { if (!options.isBindingPrepared) { return '绑定二维码还在准备中,请稍后自动刷新' } if (!options.hasDefaultRoleSnapshot) { return options.flow?.role.defaultErrorMessage || '系统还未获取到虚拟机默认角色信息,请稍后刷新' } if (!options.isRoleReady) { return '请先扫码绑定自己的角色,并刷新角色信息' } if (options.isDefaultRole) { return '当前仍是虚拟机默认角色,请重新绑定自己的角色信息' } if (options.rebindingRole) { return '正在换绑角色,请稍候' } return '' } function resolveCurrentStep(options: { isFeifeiFlow: boolean isCompleted: boolean hasRedeemResult: boolean isRoleConfirmed: boolean canEnterBindingStep: boolean }) { if (options.isFeifeiFlow) { return options.isCompleted ? 4 : 2 } if (options.hasRedeemResult) { return 4 } if (options.isRoleConfirmed) { return 3 } if (options.canEnterBindingStep) { return 2 } return 1 } function resolveProgressText(options: { feifei: ClaimKuaishouFeifeiFlowInfo | null isFeifeiFlow: boolean hasRedeemResult: boolean isRoleConfirmed: boolean isTicketVerified: boolean isBindingPrepared: boolean }) { if (options.isFeifeiFlow) { return options.feifei?.rechargeStatusLabel || '请打开领取链接' } if (options.hasRedeemResult) { return '兑换结果已生成' } if (options.isRoleConfirmed) { return '角色已确认,等待兑换' } if (options.isTicketVerified) { return options.isBindingPrepared ? '请完成扫码绑定' : '绑定链接刷新中,请稍候' } return '等待提交并核销' } 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 task: ClaimDetailData['task'] | null isRedeemFailed: boolean isCompleted: boolean }) { if (options.isRedeemFailed) { const message = String( options.task?.lastError || options.flow?.dispatch.errorMessage || 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, }: { order: ClaimOrderInfo | null product: ClaimProductInfo | null currentStep: number progressText: string }) { return (
快手 Cloud 客户领取

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

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

{[1, 2, 3, 4].map((step) => ( = step ? 'claim-step-dot active' : 'claim-step-dot'}> {step} ))}
{progressText}
) } function KuaishouCloudClaimSteps({ snapshot, ticketCode, qrCodeDataUrl, submitting, refreshingRole, confirmingRole, rebindingRole, redeeming, onTicketCodeChange, onSubmitTicket, onOpenBindUrl, onRefreshRole, onRebindRole, onConfirmRole, onConfirmRedeem, }: { snapshot: ClaimSnapshot ticketCode: string qrCodeDataUrl: string submitting: boolean refreshingRole: boolean confirmingRole: boolean rebindingRole: boolean redeeming: boolean onTicketCodeChange: (value: string) => void onSubmitTicket: () => void onOpenBindUrl: (useCurrentPage?: boolean) => void onRefreshRole: () => void onRebindRole: () => void onConfirmRole: () => void onConfirmRedeem: () => void }) { if (!snapshot.flow) { return null } if (snapshot.currentStep === 1) { return ( ) } if (snapshot.currentStep === 2) { return ( ) } if (snapshot.currentStep === 3) { return ( ) } return } function ClaimTicketStep({ ticketCode, canSubmitTicket, submitting, isBindingPreparing, flow, onTicketCodeChange, onSubmitTicket, }: { ticketCode: string canSubmitTicket: boolean submitting: boolean isBindingPreparing: boolean flow: ClaimKuaishouCloudFlowInfo onTicketCodeChange: (value: string) => void onSubmitTicket: () => void }) { return ( 第 1 步:提交核销码

请您先从快手小店复制核销码。提交后会立即核销,核销成功后系统会自动准备绑定资源。

onTicketCodeChange(event.target.value)} onPressEnter={onSubmitTicket} /> } message={ isBindingPreparing ? '核销完成后会自动准备绑定资源。' : '核销完成后会自动进入扫码绑定步骤。' } /> {flow.guideImages.length > 0 ? (
{flow.guideImages.map((imageUrl, index) => (
{`步骤
步骤 {index + 1}
))}
), }, ]} /> ) : null}
) } function ClaimBindingStep({ snapshot, qrCodeDataUrl, refreshingRole, confirmingRole, rebindingRole, onOpenBindUrl, onRefreshRole, onRebindRole, onConfirmRole, }: { snapshot: ClaimSnapshot qrCodeDataUrl: string refreshingRole: boolean confirmingRole: boolean rebindingRole: boolean onOpenBindUrl: (useCurrentPage?: boolean) => void onRefreshRole: () => void onRebindRole: () => void onConfirmRole: () => void }) { const flow = snapshot.flow if (!flow) { return null } return ( {snapshot.isBindingPrepared && qrCodeDataUrl ? (
Q区用Q扫
绑定二维码
V区用V扫

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

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

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

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

{snapshot.resultDescription}

) } function FeifeiClaimPanel({ feifei, order, product, taskLastError, onOpen, }: { feifei: ClaimKuaishouFeifeiFlowInfo order: ClaimOrderInfo | null product: ClaimProductInfo | null taskLastError?: string onOpen: () => void }) { return (
kuaishou-feifei {product?.title || '商品领取'}

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

{taskLastError ? : null}
) } 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}
) }