1163 lines
33 KiB
TypeScript
1163 lines
33 KiB
TypeScript
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<typeof createClaimSnapshot>
|
|
|
|
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<ClaimDetailData | null>(null)
|
|
const [ticketCode, setTicketCode] = useState('')
|
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('')
|
|
|
|
const pollTimerRef = useRef<number>(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 (
|
|
<main className="claim-page">
|
|
<ClaimHeaderCard
|
|
order={snapshot.order}
|
|
product={snapshot.product}
|
|
currentStep={snapshot.currentStep}
|
|
progressText={snapshot.progressText}
|
|
/>
|
|
|
|
{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>
|
|
) : snapshot.isFeifeiFlow && snapshot.feifei ? (
|
|
<FeifeiClaimPanel
|
|
feifei={snapshot.feifei}
|
|
order={snapshot.order}
|
|
product={snapshot.product}
|
|
taskLastError={snapshot.task?.lastError}
|
|
onOpen={() => openFeifeiUrl(false)}
|
|
/>
|
|
) : detail && snapshot.flow ? (
|
|
<KuaishouCloudClaimSteps
|
|
snapshot={snapshot}
|
|
ticketCode={ticketCode}
|
|
qrCodeDataUrl={qrCodeDataUrl}
|
|
submitting={submitting}
|
|
refreshingRole={refreshingRole}
|
|
confirmingRole={confirmingRole}
|
|
rebindingRole={rebindingRole}
|
|
redeeming={redeeming}
|
|
onTicketCodeChange={setTicketCode}
|
|
onSubmitTicket={submitTicket}
|
|
onOpenBindUrl={openBindUrl}
|
|
onRefreshRole={refreshRole}
|
|
onRebindRole={rebindRole}
|
|
onConfirmRole={confirmRole}
|
|
onConfirmRedeem={confirmRedeem}
|
|
/>
|
|
) : (
|
|
<Card className="claim-content-card claim-state-card">
|
|
<Empty description="流程数据不完整,请联系客服处理" />
|
|
</Card>
|
|
)}
|
|
</main>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Card className="claim-header-card">
|
|
<div className="claim-header-copy">
|
|
<span className="eyebrow">快手 Cloud 客户领取</span>
|
|
<h1>{product?.title || '商品领取'}</h1>
|
|
<p>订单号:{order?.platformOrderId || '-'}</p>
|
|
<ClaimProductItems product={product} compact />
|
|
</div>
|
|
|
|
<div className="claim-step-indicator">
|
|
<div className="claim-step-dots" aria-label={`当前第 ${currentStep} 步`}>
|
|
{[1, 2, 3, 4].map((step) => (
|
|
<span key={step} className={currentStep >= step ? 'claim-step-dot active' : 'claim-step-dot'}>
|
|
{step}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<span className="claim-step-progress-text">{progressText}</span>
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<ClaimTicketStep
|
|
ticketCode={ticketCode}
|
|
canSubmitTicket={snapshot.canSubmitTicket && !submitting}
|
|
submitting={submitting}
|
|
isBindingPreparing={snapshot.isBindingPreparing}
|
|
flow={snapshot.flow}
|
|
onTicketCodeChange={onTicketCodeChange}
|
|
onSubmitTicket={onSubmitTicket}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (snapshot.currentStep === 2) {
|
|
return (
|
|
<ClaimBindingStep
|
|
snapshot={snapshot}
|
|
qrCodeDataUrl={qrCodeDataUrl}
|
|
refreshingRole={refreshingRole}
|
|
confirmingRole={confirmingRole}
|
|
rebindingRole={rebindingRole}
|
|
onOpenBindUrl={onOpenBindUrl}
|
|
onRefreshRole={onRefreshRole}
|
|
onRebindRole={onRebindRole}
|
|
onConfirmRole={onConfirmRole}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (snapshot.currentStep === 3) {
|
|
return (
|
|
<ClaimConfirmStep
|
|
roleName={snapshot.roleName}
|
|
roleId={snapshot.roleId}
|
|
product={snapshot.product}
|
|
redeeming={redeeming}
|
|
rebindingRole={rebindingRole}
|
|
onConfirmRedeem={onConfirmRedeem}
|
|
onRebindRole={onRebindRole}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return <ClaimResultStep snapshot={snapshot} />
|
|
}
|
|
|
|
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 (
|
|
<Card className="claim-content-card">
|
|
<Typography.Title level={2}>第 1 步:提交核销码</Typography.Title>
|
|
<p className="claim-muted">
|
|
请您先从快手小店复制核销码。提交后会立即核销,核销成功后系统会自动准备绑定资源。
|
|
</p>
|
|
|
|
<Input
|
|
size="large"
|
|
value={ticketCode}
|
|
placeholder="粘贴快手核销码"
|
|
disabled={!canSubmitTicket}
|
|
onChange={(event) => onTicketCodeChange(event.target.value)}
|
|
onPressEnter={onSubmitTicket}
|
|
/>
|
|
|
|
<Button
|
|
type="primary"
|
|
size="large"
|
|
icon={<SendOutlined />}
|
|
loading={submitting}
|
|
disabled={!canSubmitTicket}
|
|
onClick={onSubmitTicket}
|
|
>
|
|
提交核销码并继续
|
|
</Button>
|
|
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
icon={<ClockCircleOutlined />}
|
|
message={
|
|
isBindingPreparing
|
|
? '核销完成后会自动准备绑定资源。'
|
|
: '核销完成后会自动进入扫码绑定步骤。'
|
|
}
|
|
/>
|
|
|
|
{flow.guideImages.length > 0 ? (
|
|
<Collapse
|
|
defaultActiveKey={['guide']}
|
|
items={[
|
|
{
|
|
key: 'guide',
|
|
label: '查看核销码图文指引',
|
|
children: (
|
|
<Image.PreviewGroup>
|
|
<div className="claim-guide-grid">
|
|
{flow.guideImages.map((imageUrl, index) => (
|
|
<figure key={imageUrl} className="claim-guide-figure">
|
|
<Image src={imageUrl} alt={`步骤 ${index + 1}`} />
|
|
<figcaption>步骤 {index + 1}</figcaption>
|
|
</figure>
|
|
))}
|
|
</div>
|
|
</Image.PreviewGroup>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
) : null}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Card className="claim-content-card claim-binding-card">
|
|
{snapshot.isBindingPrepared && qrCodeDataUrl ? (
|
|
<div className="claim-qr-block">
|
|
<div className="claim-qr-guide-panel">
|
|
<div className="claim-scan-label claim-scan-label-left">Q区用Q扫</div>
|
|
<img src={qrCodeDataUrl} alt="绑定二维码" className="claim-qr-code" />
|
|
<div className="claim-scan-label claim-scan-label-right">V区用V扫</div>
|
|
<p className="claim-save-hint">长按保存相册进行扫码绑定</p>
|
|
</div>
|
|
|
|
<Button icon={<LinkOutlined />} onClick={() => onOpenBindUrl(false)}>
|
|
打开绑定链接
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
icon={<LoadingOutlined spin />}
|
|
message="系统正在准备绑定资源,请稍后自动刷新。"
|
|
/>
|
|
)}
|
|
|
|
<div className={snapshot.isCustomerRoleReady ? 'claim-role-panel' : 'claim-role-panel pending'}>
|
|
<InfoRow label="当前角色" value={snapshot.roleName || '待识别'} />
|
|
<InfoRow label="角色 ID" value={snapshot.roleId || '-'} />
|
|
</div>
|
|
|
|
{flow.role.isDefaultRole ? (
|
|
<Alert type="warning" showIcon message="当前仍是虚拟机默认角色,请重新绑定自己的角色信息。" />
|
|
) : null}
|
|
|
|
<Space size={12} wrap className="claim-action-row">
|
|
<Button size="large" icon={<ReloadOutlined />} loading={refreshingRole} onClick={onRefreshRole}>
|
|
刷新角色信息
|
|
</Button>
|
|
<Button
|
|
size="large"
|
|
icon={<SwapOutlined />}
|
|
loading={rebindingRole}
|
|
disabled={confirmingRole}
|
|
onClick={onRebindRole}
|
|
>
|
|
换绑角色
|
|
</Button>
|
|
<Tooltip title={snapshot.confirmRoleDisabledReason || undefined}>
|
|
<span className="claim-action-tooltip-wrap">
|
|
<Button
|
|
type="primary"
|
|
size="large"
|
|
icon={<CheckCircleOutlined />}
|
|
loading={confirmingRole}
|
|
disabled={Boolean(snapshot.confirmRoleDisabledReason)}
|
|
onClick={onConfirmRole}
|
|
>
|
|
我已完成绑定,下一步
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
</Space>
|
|
|
|
<div className="claim-meta-footer">
|
|
<span>最近刷新:{formatAdminDateTime(flow.role.refreshedAt)}</span>
|
|
<span>链接有效期:{formatAdminDateTime(flow.binding.bindExpiresAt)}</span>
|
|
{flow.binding.bindProbeMessage ? (
|
|
<span>
|
|
链接检测:{flow.binding.bindProbeStatus || '-'} / {flow.binding.bindProbeMessage}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Card className="claim-content-card">
|
|
<Typography.Title level={2}>第 3 步:确认兑换信息</Typography.Title>
|
|
<p className="claim-muted">请再次确认角色和商品信息,确认无误后再继续兑换。</p>
|
|
|
|
<div className="claim-info-grid">
|
|
<InfoTile label="角色名称" value={roleName || '-'} />
|
|
<InfoTile label="角色 ID" value={roleId || '-'} />
|
|
<InfoTile label="领取商品" value={product?.title || '-'} />
|
|
<InfoTile label="商品数量" value={String(product?.quantity || 0)} />
|
|
</div>
|
|
|
|
<ClaimProductItems product={product} />
|
|
|
|
<Alert type="warning" showIcon message="兑换后不可取消,也不可退货。" />
|
|
|
|
<Space size={12} wrap className="claim-action-row">
|
|
<Button
|
|
size="large"
|
|
icon={<SwapOutlined />}
|
|
loading={rebindingRole}
|
|
disabled={redeeming}
|
|
onClick={onRebindRole}
|
|
>
|
|
换绑角色
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
size="large"
|
|
icon={<CheckCircleOutlined />}
|
|
loading={redeeming}
|
|
disabled={rebindingRole}
|
|
onClick={onConfirmRedeem}
|
|
>
|
|
确认兑换
|
|
</Button>
|
|
</Space>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) {
|
|
const flow = snapshot.flow
|
|
if (!flow) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Card className="claim-content-card">
|
|
<div className="claim-result-header">
|
|
{snapshot.resultVariant === 'warning' ? (
|
|
<ExclamationCircleOutlined className="claim-large-icon claim-icon-warning" />
|
|
) : (
|
|
<CheckCircleOutlined className={`claim-large-icon claim-icon-${snapshot.resultVariant}`} />
|
|
)}
|
|
<div>
|
|
<Typography.Title level={2}>{snapshot.resultTitle}</Typography.Title>
|
|
<p className="claim-muted">{snapshot.resultDescription}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="claim-info-grid">
|
|
<InfoTile label="结果时间" value={formatAdminDateTime(flow.dispatch.dispatchAt)} />
|
|
<InfoTile label="领取商品" value={snapshot.product?.title || '-'} />
|
|
<InfoTile label="角色名称" value={snapshot.roleName || '-'} />
|
|
<InfoTile label="角色 ID" value={snapshot.roleId || '-'} />
|
|
<InfoTile label="订单号" value={snapshot.order?.platformOrderId || '-'} />
|
|
<InfoTile label="购买数量" value={String(snapshot.product?.quantity || 0)} />
|
|
</div>
|
|
|
|
<ClaimProductItems product={snapshot.product} />
|
|
|
|
<Alert
|
|
type={snapshot.resultVariant}
|
|
showIcon
|
|
message={
|
|
snapshot.resultVariant === 'warning'
|
|
? '当前兑换未完成,客服会根据后台任务记录继续处理。'
|
|
: '客户侧操作已经完成,后续履约结果请以后台任务界面为准。'
|
|
}
|
|
/>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function FeifeiClaimPanel({
|
|
feifei,
|
|
order,
|
|
product,
|
|
taskLastError,
|
|
onOpen,
|
|
}: {
|
|
feifei: ClaimKuaishouFeifeiFlowInfo
|
|
order: ClaimOrderInfo | null
|
|
product: ClaimProductInfo | null
|
|
taskLastError?: string
|
|
onOpen: () => void
|
|
}) {
|
|
return (
|
|
<Card className="claim-content-card">
|
|
<div className="claim-feifei-main">
|
|
<span className="claim-feifei-label">kuaishou-feifei</span>
|
|
<Typography.Title level={2}>{product?.title || '商品领取'}</Typography.Title>
|
|
<p>{feifei.rechargeStatusLabel || '待领取'}</p>
|
|
</div>
|
|
|
|
<div className="claim-info-grid">
|
|
<InfoTile label="订单号" value={order?.platformOrderId || '-'} />
|
|
<InfoTile label="平台单号" value={feifei.orderNo || feifei.platformOrderNo || '-'} />
|
|
</div>
|
|
|
|
<Button
|
|
type="primary"
|
|
size="large"
|
|
icon={<LinkOutlined />}
|
|
disabled={!feifei.h5.rechargeUrl && !feifei.h5.entryUrl}
|
|
onClick={onOpen}
|
|
>
|
|
打开领取链接
|
|
</Button>
|
|
|
|
{taskLastError ? <Alert type="warning" showIcon message={taskLastError} /> : null}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className={compact ? 'claim-product-items compact' : 'claim-product-items'}>
|
|
<div className="claim-product-items-header">
|
|
<span>{hasMultipleItems ? '包含商品' : '商品明细'}</span>
|
|
{hasMultipleItems ? <strong>{items.length} 项</strong> : null}
|
|
</div>
|
|
|
|
<div className="claim-product-items-list">
|
|
{items.map((item) => (
|
|
<div key={item.key} className="claim-product-item-row">
|
|
<span>{item.name}</span>
|
|
<strong>x{item.quantity}</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="claim-info-item">
|
|
<span>{label}</span>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="claim-role-panel-item">
|
|
<span>{label}</span>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
)
|
|
}
|