Files
order_site/apps/frontend/src/pages/claim/ClaimPage.tsx
T
2026-07-07 19:55:18 +08:00

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>
)
}