Files
order_site/apps/frontend/src/pages/claim/ClaimPage.tsx
T
yml2213 5ef1ed6018 新增 affiliate_dash 统一领取页流程(阶段 4)
- 后端:claim 详情分流 affiliate_dash 分支 + 详情轮询刷新 bind-result
- 输 UID 触发 bind(拿 bindUuid/二维码, task→waiting_binding)
- 新增 POST /claim/:token/affiliate-dash/submit(submit→redeeming + 事件)
- 前端:ClaimAffiliateDashSteps(二维码/绑定状态/mismatch警告/提交发货)
- claim-snapshot/claim-poll 按 flowType 分流(step2 绑定中/step3 已绑定待提交/step4 结果)
- 前后端 typecheck + 后端 212 测试通过
2026-08-05 15:56:51 +08:00

431 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { isFeedbackDismissed, showConfirm, showError, showSuccess } from '@/lib/feedback'
import {
confirmKuaishouCloudClaimRole,
fetchClaimDetail,
rebindKuaishouCloudClaimRole,
redeemKuaishouCloudClaim,
submitAffiliateDashClaim,
submitClaimUid,
} from '@/services/claim'
import type { ClaimDetailData } from '@/types/claim'
import { AffiliateDashClaimPanel, AffiliateDashResultStep } from './ClaimAffiliateDashSteps'
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 }>()
const token = String(routeToken || '').trim()
const [loading, setLoading] = useState(true)
const [submittingUid, setSubmittingUid] = useState(false)
const [uidInput, setUidInput] = useState('')
const [refreshingRole, setRefreshingRole] = useState(false)
const [confirmingRole, setConfirmingRole] = useState(false)
const [rebindingRole, setRebindingRole] = useState(false)
const [redeeming, setRedeeming] = useState(false)
const [submittingSubmit, setSubmittingSubmit] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [detail, setDetail] = useState<ClaimDetailData | null>(null)
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('')
const pollTimerRef = useRef(0)
const rolePollBaselineRef = useRef({ roleKey: '', at: 0 })
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)
const expectedUid = String(nextDetail.claimIdentity?.expectedUid || '').trim()
if (expectedUid) {
setUidInput(expectedUid)
}
await generateQRCode(
String(
nextDetail.kuaishouCloudFulfillment?.binding.bindUrl ||
nextDetail.affiliateDash?.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 resolveNextPollDelay = useCallback((nextSnapshot: ClaimSnapshot) => {
const rolePoll = resolveRolePollDelayMs(nextSnapshot, rolePollBaselineRef.current)
rolePollBaselineRef.current = rolePoll.baseline
return resolveNextPollDelayMs(nextSnapshot, rolePoll.delay)
}, [])
useEffect(() => {
rolePollBaselineRef.current = { roleKey: '', at: 0 }
setDetail(null)
setQrCodeDataUrl('')
setUidInput('')
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 handleSubmitUid() {
const uid = uidInput.trim()
if (!uid) {
showError('请输入游戏 UID')
return
}
setSubmittingUid(true)
try {
const response = await submitClaimUid(token, uid)
await applyDetail(response.data)
showSuccess('UID 已提交')
} catch (error) {
showError(error instanceof Error ? error.message : '提交 UID 失败')
} finally {
setSubmittingUid(false)
}
}
async function handleSubmitAffiliateDash() {
const affiliateDash = snapshot.affiliateDash
const uid = uidInput.trim() || affiliateDash?.gameAccount || ''
const bindUuid = affiliateDash?.bindUuid || ''
if (!uid || !bindUuid) {
showError('绑定尚未完成,请先绑定账号')
return
}
setSubmittingSubmit(true)
try {
const response = await submitAffiliateDashClaim(token, { gameAccount: uid, bindUuid })
await applyDetail(response.data)
showSuccess('发货已提交')
} catch (error) {
showError(error instanceof Error ? error.message : '提交发货失败')
} finally {
setSubmittingSubmit(false)
}
}
async function refreshRole() {
setRefreshingRole(true)
try {
const nextDetail = await loadDetail({ silent: true })
const nextSnapshot = createClaimSnapshot(nextDetail, { rebindingRole })
if (nextSnapshot.isUidMatched) {
showSuccess('角色信息已匹配,可确认兑换')
} else if (nextSnapshot.roleId) {
showError(
`当前绑定角色 ID${nextSnapshot.roleId})与填写 UID${nextSnapshot.expectedUid || '-'})不一致`,
)
} else if (nextDetail) {
showError(
nextSnapshot.flow?.role.errorMessage ||
'暂时还没有识别到角色信息,请完成绑定后稍等片刻再试',
)
}
} 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('兑换后不可取消,也不可退货。请确认 UID 与商品信息完全正确。', '确认兑换', {
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(
'换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。请使用你填写的 UID 对应角色重新绑定。',
'确认换绑角色',
{
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 || snapshot.affiliateDash?.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?.h5UrlWithUid ||
snapshot.feifei?.h5.rechargeUrl ||
snapshot.feifei?.h5.entryUrl ||
'',
).trim()
if (!snapshot.hasExpectedUid) {
showError('请先填写游戏 UID')
return
}
if (!url) {
showError('领取链接还没准备好,请稍后刷新')
return
}
if (useCurrentPage) {
window.location.assign(url)
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
return (
<main className="claim-page">
<ClaimHeaderCard
order={snapshot.order}
product={snapshot.product}
currentStep={snapshot.currentStep}
progressText={snapshot.progressText}
expectedUid={snapshot.expectedUid}
/>
{loading ? (
<Card className="claim-content-card claim-state-card">
<Spin indicator={<LoadingOutlined spin />} size="large" />
<p>正在读取当前领取进度...</p>
</Card>
) : errorMessage ? (
<Card className="claim-content-card claim-state-card">
<ExclamationCircleOutlined className="claim-large-icon claim-icon-error" />
<p>{errorMessage}</p>
</Card>
) : !detail ? (
<Card className="claim-content-card claim-state-card">
<Empty description="流程数据不完整,请联系客服处理" />
</Card>
) : snapshot.currentStep === 1 ? (
<ClaimUidStep
uidInput={uidInput}
submitting={submittingUid}
onChange={setUidInput}
onSubmit={() => void handleSubmitUid()}
/>
) : snapshot.hasRedeemResult ? (
snapshot.isFeifeiFlow ? (
<FeifeiResultStep snapshot={snapshot} />
) : snapshot.isAffiliateDashFlow ? (
<AffiliateDashResultStep snapshot={snapshot} />
) : (
<ClaimResultStep snapshot={snapshot} />
)
) : snapshot.isAffiliateDashFlow && snapshot.affiliateDash ? (
<AffiliateDashClaimPanel
affiliateDash={snapshot.affiliateDash}
expectedUid={snapshot.expectedUid}
order={snapshot.order}
product={snapshot.product}
taskLastError={snapshot.task?.lastError}
qrCodeDataUrl={qrCodeDataUrl}
submittingUid={submittingUid}
submittingSubmit={submittingSubmit}
uidInput={uidInput}
onUidChange={setUidInput}
onResubmitUid={() => void handleSubmitUid()}
onSubmit={() => void handleSubmitAffiliateDash()}
onOpenBindUrl={openBindUrl}
/>
) : snapshot.isFeifeiFlow && snapshot.feifei ? (
<FeifeiClaimPanel
feifei={snapshot.feifei}
expectedUid={snapshot.expectedUid}
order={snapshot.order}
product={snapshot.product}
taskLastError={snapshot.task?.lastError}
onOpen={() => openFeifeiUrl(false)}
uidInput={uidInput}
submittingUid={submittingUid}
onUidChange={setUidInput}
onResubmitUid={() => void handleSubmitUid()}
/>
) : snapshot.flow ? (
<KuaishouCloudClaimSteps
snapshot={snapshot}
qrCodeDataUrl={qrCodeDataUrl}
refreshingRole={refreshingRole}
confirmingRole={confirmingRole}
rebindingRole={rebindingRole}
redeeming={redeeming}
onOpenBindUrl={openBindUrl}
onRefreshRole={refreshRole}
onRebindRole={rebindRole}
onConfirmRole={confirmRole}
onConfirmRedeem={confirmRedeem}
uidInput={uidInput}
submittingUid={submittingUid}
onUidChange={setUidInput}
onResubmitUid={() => void handleSubmitUid()}
/>
) : (
<Card className="claim-content-card claim-state-card">
<Empty description="流程数据不完整,请联系客服处理" />
</Card>
)}
</main>
)
}