- 后端: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 测试通过
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|