Files
order_site/apps/frontend/src/views/claim/KuaishouCloudClaimView.vue
T

840 lines
20 KiB
Vue
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.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import QRCode from 'qrcode'
import { ElMessageBox } from 'element-plus'
import {
CircleCheck,
CircleClose,
Clock,
Loading,
RefreshRight,
Warning,
} from '@element-plus/icons-vue'
import { showError, showSuccess } from '@/lib/feedback'
import {
confirmKuaishouCloudClaimRole,
fetchClaimDetail,
redeemKuaishouCloudClaim,
verifyKuaishouCloudClaimTicket,
} from '@/services/claim'
import type { ClaimDetailData } from '@/types/claim'
const props = defineProps<{
token: string
}>()
const loading = ref(true)
const submitting = ref(false)
const refreshingRole = ref(false)
const confirmingRole = ref(false)
const redeeming = ref(false)
const errorMessage = ref('')
const detail = ref<ClaimDetailData | null>(null)
const ticketCode = ref('')
const qrCodeDataUrl = ref('')
let pollTimer = 0
const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
const order = computed(() => detail.value?.order || null)
const orderItem = computed(() => detail.value?.orderItem || null)
const task = computed(() => detail.value?.task || null)
const roleName = computed(() => flow.value?.role.name || flow.value?.binding.roleName || '')
const roleId = computed(() => flow.value?.role.rid || flow.value?.binding.roleId || '')
const isTicketVerified = computed(() => flow.value?.ticket.status === 'verified')
const isBindUrlExpired = computed(() => {
const expiresAt = String(flow.value?.binding.bindExpiresAt || '').trim()
if (!expiresAt) {
return false
}
const expiresTime = Date.parse(expiresAt)
return Number.isFinite(expiresTime) && expiresTime <= Date.now()
})
const isBindingPrepared = computed(
() =>
flow.value?.binding.prepareStatus === 'ready' &&
Boolean(String(flow.value?.binding.bindUrl || '').trim()) &&
!isBindUrlExpired.value,
)
const isBindingPreparing = computed(() => flow.value?.binding.prepareStatus === 'pending')
const canEnterBindingStep = computed(() => isTicketVerified.value)
const isRoleReady = computed(() => Boolean(roleName.value || roleId.value))
const isRoleConfirmed = computed(() => String(task.value?.status || '').trim() === 'role_confirmed')
const isDispatched = computed(() => String(flow.value?.dispatch.status || '').trim() === 'success')
const isCompleted = computed(
() =>
String(task.value?.status || '').trim() === 'completed' ||
String(flow.value?.consume.status || '').trim() === 'success',
)
const hasRedeemResult = computed(() => {
const status = String(task.value?.status || '').trim()
return (
isDispatched.value ||
['dispatched_pending_return', 'completed', 'manual_review', 'failed'].includes(status)
)
})
const canSubmitTicket = computed(
() =>
!['completed', 'manual_review', 'closed', 'expired'].includes(
String(task.value?.status || '').trim(),
) && !submitting.value,
)
const currentStep = computed(() => {
if (hasRedeemResult.value) {
return 4
}
if (isRoleConfirmed.value) {
return 3
}
if (canEnterBindingStep.value) {
return 2
}
return 1
})
const progressText = computed(() => {
if (hasRedeemResult.value) {
return '兑换结果已生成'
}
if (isRoleConfirmed.value) {
return '角色已确认,等待兑换'
}
if (isTicketVerified.value) {
return isBindingPrepared.value ? '请完成扫码绑定' : '绑定链接刷新中,请稍候'
}
return '等待提交核销码'
})
const resultTitle = computed(() => {
if (isCompleted.value) {
return '兑换成功'
}
if (isDispatched.value) {
return '兑换请求已提交'
}
return '结果已记录'
})
const resultDescription = computed(() => {
if (isCompleted.value) {
return '当前兑换流程已经完成。发货、退号和核销结果会保存在后台任务界面。'
}
return '你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。'
})
async function generateQRCode(url: string) {
if (!url) {
qrCodeDataUrl.value = ''
return
}
try {
qrCodeDataUrl.value = await QRCode.toDataURL(url, {
width: 280,
margin: 2,
color: {
dark: '#0f172a',
light: '#ffffff',
},
})
} catch (error) {
qrCodeDataUrl.value = ''
console.error('生成二维码失败:', error)
}
}
async function applyDetail(nextDetail: ClaimDetailData) {
detail.value = nextDetail
if (!ticketCode.value && nextDetail.kuaishouCloudFulfillment?.ticket.code) {
ticketCode.value = nextDetail.kuaishouCloudFulfillment.ticket.code
}
await generateQRCode(String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || '').trim())
}
async function loadDetail(options: { silent?: boolean } = {}) {
if (!options.silent) {
loading.value = true
}
errorMessage.value = ''
try {
const response = await fetchClaimDetail(props.token)
await applyDetail(response.data)
syncPolling()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取领取信息失败'
stopPolling()
} finally {
if (!options.silent) {
loading.value = false
}
}
}
async function submitTicket() {
const normalizedTicketCode = ticketCode.value.trim()
if (!normalizedTicketCode) {
showError('请输入核销码')
return
}
submitting.value = true
try {
const response = await verifyKuaishouCloudClaimTicket(props.token, {
ticketCode: normalizedTicketCode,
})
await applyDetail(response.data)
showSuccess('核销码验证通过,系统已开始准备绑定资源')
syncPolling()
} catch (error) {
showError(error instanceof Error ? error.message : '核销码验证失败')
} finally {
submitting.value = false
}
}
async function refreshRole() {
refreshingRole.value = true
try {
await loadDetail({ silent: true })
if (isRoleReady.value) {
showSuccess('角色信息已刷新')
} else {
showError(
flow.value?.role.errorMessage || '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试',
)
}
} finally {
refreshingRole.value = false
}
}
async function confirmRole() {
confirmingRole.value = true
try {
const response = await confirmKuaishouCloudClaimRole(props.token)
await applyDetail(response.data)
showSuccess('角色已确认,进入下一步')
syncPolling()
} catch (error) {
showError(error instanceof Error ? error.message : '确认角色失败')
} finally {
confirmingRole.value = false
}
}
async function confirmRedeem() {
try {
await ElMessageBox.confirm(
'兑换后不可取消,也不可退货。请确认角色和商品信息完全正确。',
'确认兑换',
{
confirmButtonText: '确认兑换',
cancelButtonText: '取消',
type: 'warning',
center: true,
},
)
} catch {
return
}
redeeming.value = true
try {
const response = await redeemKuaishouCloudClaim(props.token)
await applyDetail(response.data)
showSuccess('兑换请求已提交')
syncPolling()
} catch (error) {
showError(error instanceof Error ? error.message : '兑换失败')
} finally {
redeeming.value = false
}
}
function openBindUrl(useCurrentPage = false) {
const bindUrl = String(flow.value?.binding.bindUrl || '').trim()
if (!bindUrl) {
showError('绑定链接还没准备好,请稍后刷新')
return
}
if (useCurrentPage) {
window.location.assign(bindUrl)
return
}
window.open(bindUrl, '_blank', 'noopener,noreferrer')
}
function stopPolling() {
if (pollTimer) {
window.clearInterval(pollTimer)
pollTimer = 0
}
}
function syncPolling() {
const taskStatus = String(task.value?.status || '').trim()
const shouldPoll =
Boolean(flow.value) && !hasRedeemResult.value && !['closed', 'expired'].includes(taskStatus)
if (!shouldPoll) {
stopPolling()
return
}
if (pollTimer) {
return
}
pollTimer = window.setInterval(() => {
void loadDetail({ silent: true })
}, 4000)
}
function formatDateTime(value: string | null) {
if (!value) {
return '-'
}
try {
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return value
}
}
onMounted(() => {
void loadDetail()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<template>
<main class="claim-page">
<section class="header-card">
<div class="header-copy">
<span class="eyebrow">快手 Cloud 客户领取</span>
<h1>{{ orderItem?.skuName || '商品领取' }}</h1>
<p>订单号{{ order?.platformOrderId || '-' }}</p>
</div>
<div v-if="!loading && !errorMessage" class="step-indicator">
<div
v-for="step in 4"
:key="step"
class="step-dot"
:class="{ active: currentStep >= step }"
>
{{ step }}
</div>
</div>
</section>
<div v-if="loading" class="content-card loading-card">
<el-icon class="large-icon spinning"><Loading /></el-icon>
<p>正在读取当前领取进度...</p>
</div>
<div v-else-if="errorMessage" class="content-card error-card">
<el-icon class="large-icon error"><CircleClose /></el-icon>
<p>{{ errorMessage }}</p>
</div>
<template v-else-if="detail && flow">
<section class="summary-card">
<div class="summary-item">
<span>当前进度</span>
<strong>{{ progressText }}</strong>
</div>
<div class="summary-item">
<span>当前角色</span>
<strong>{{ roleName || '待识别' }}</strong>
</div>
<div class="summary-item">
<span>角色 ID</span>
<strong>{{ roleId || '-' }}</strong>
</div>
</section>
<section v-if="currentStep === 1" class="content-card">
<h2> 1 提交核销码</h2>
<p class="muted">
请先从快手小店复制核销码验证通过后系统会自动准备 Cloud
绑定资源不需要再让客服手工点准备绑定资源”。
</p>
<el-input
v-model="ticketCode"
placeholder="粘贴快手核销码"
size="large"
:disabled="!canSubmitTicket"
@keyup.enter="submitTicket"
/>
<el-button
type="primary"
size="large"
:loading="submitting"
:disabled="!canSubmitTicket"
@click="submitTicket"
>
验证核销码并继续
</el-button>
<div class="status-banner info">
<el-icon><Clock /></el-icon>
<span>{{
isBindingPreparing
? '绑定资源会在核销通过后自动准备。'
: '核销完成后会自动进入扫码绑定步骤。'
}}</span>
</div>
<div v-if="flow.guideImages.length > 0" class="guide-section">
<el-collapse>
<el-collapse-item title="查看核销码图文指引" name="guide">
<div class="guide-grid">
<figure
v-for="(imageUrl, index) in flow.guideImages"
:key="imageUrl"
class="guide-figure"
>
<img :src="imageUrl" :alt="`步骤 ${index + 1}`" />
<figcaption>步骤 {{ index + 1 }}</figcaption>
</figure>
</div>
</el-collapse-item>
</el-collapse>
</div>
</section>
<section v-else-if="currentStep === 2" class="content-card">
<h2> 2 扫码绑定角色</h2>
<p class="muted">
核销码已验证通过请扫码或直接打开绑定页面绑定你自己的游戏角色绑定信息如果一开始不对可以重新绑定后再点下一步
</p>
<div v-if="isBindingPrepared && qrCodeDataUrl" class="qr-block">
<img :src="qrCodeDataUrl" alt="绑定二维码" class="qr-code" />
<div class="action-row">
<el-button type="primary" size="large" @click="openBindUrl()">
新窗口打开绑定页
</el-button>
<el-button size="large" plain @click="openBindUrl(true)"> 当前页面打开 </el-button>
</div>
</div>
<div v-else class="status-banner info">
<el-icon class="spinning"><Loading /></el-icon>
<span>系统正在准备绑定资源请稍后自动刷新</span>
</div>
<p v-if="flow.role.errorMessage" class="muted warning-line">
{{ flow.role.errorMessage }}
</p>
<p v-else class="muted warning-line">
如果当前角色不正确请重新打开绑定页完成重新绑定然后刷新角色信息
</p>
<p class="muted meta-line">
最近刷新{{ formatDateTime(flow.role.refreshedAt) }}链接有效期{{
formatDateTime(flow.binding.bindExpiresAt)
}}
</p>
<p v-if="flow.binding.bindProbeMessage" class="muted meta-line">
链接检测{{ flow.binding.bindProbeStatus || '-' }} / {{ flow.binding.bindProbeMessage }}
</p>
<div class="action-row">
<el-button size="large" plain :loading="refreshingRole" @click="refreshRole">
<el-icon><RefreshRight /></el-icon>
刷新角色信息
</el-button>
<el-button
type="primary"
size="large"
:loading="confirmingRole"
:disabled="!isBindingPrepared || !isRoleReady"
@click="confirmRole"
>
我已完成绑定下一步
</el-button>
</div>
</section>
<section v-else-if="currentStep === 3" class="content-card">
<h2> 3 确认兑换信息</h2>
<p class="muted">请再次确认角色和商品信息确认无误后再继续兑换</p>
<div class="info-grid">
<div class="info-item">
<span>角色名称</span>
<strong>{{ roleName || '-' }}</strong>
</div>
<div class="info-item">
<span>角色 ID</span>
<strong>{{ roleId || '-' }}</strong>
</div>
<div class="info-item">
<span>商品名称</span>
<strong>{{ orderItem?.skuName || '-' }}</strong>
</div>
<div class="info-item">
<span>商品数量</span>
<strong>{{ orderItem?.quantity || 0 }}</strong>
</div>
</div>
<div class="status-banner warning">
<el-icon><Warning /></el-icon>
<span>兑换后不可取消也不可退货</span>
</div>
<el-button type="primary" size="large" :loading="redeeming" @click="confirmRedeem">
确认兑换
</el-button>
</section>
<section v-else class="content-card">
<div class="result-header">
<el-icon class="large-icon success"><CircleCheck /></el-icon>
<div>
<h2>{{ resultTitle }}</h2>
<p class="muted">{{ resultDescription }}</p>
</div>
</div>
<div class="info-grid">
<div class="info-item">
<span>结果时间</span>
<strong>{{ formatDateTime(flow.dispatch.dispatchAt) }}</strong>
</div>
<div class="info-item">
<span>商品名称</span>
<strong>{{ orderItem?.skuName || '-' }}</strong>
</div>
<div class="info-item">
<span>角色名称</span>
<strong>{{ roleName || '-' }}</strong>
</div>
<div class="info-item">
<span>角色 ID</span>
<strong>{{ roleId || '-' }}</strong>
</div>
<div class="info-item">
<span>订单号</span>
<strong>{{ order?.platformOrderId || '-' }}</strong>
</div>
<div class="info-item">
<span>购买数量</span>
<strong>{{ orderItem?.quantity || 0 }}</strong>
</div>
</div>
<div class="status-banner success">
<el-icon><CircleCheck /></el-icon>
<span>客户侧操作已经完成后续履约结果请以后台任务界面为准</span>
</div>
</section>
</template>
<div v-else class="content-card error-card">
<el-icon class="large-icon error"><CircleClose /></el-icon>
<p>流程数据不完整请联系客服处理</p>
</div>
</main>
</template>
<style scoped>
.claim-page {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 28%),
linear-gradient(180deg, #f8fafc 0%, #eef4fb 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.header-card,
.summary-card,
.content-card {
width: 100%;
max-width: 680px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 20px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(12px);
}
.header-card {
padding: 24px 28px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.header-copy h1,
.content-card h2 {
margin: 0;
color: #0f172a;
}
.header-copy p,
.muted {
margin: 0;
color: #64748b;
line-height: 1.6;
}
.eyebrow {
display: inline-flex;
margin-bottom: 8px;
color: #2563eb;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
}
.step-indicator {
display: flex;
gap: 10px;
}
.step-dot {
width: 34px;
height: 34px;
border-radius: 999px;
background: #e2e8f0;
color: #94a3b8;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.step-dot.active {
background: linear-gradient(135deg, #2563eb 0%, #0ea5e9 100%);
color: #ffffff;
}
.summary-card {
padding: 18px 22px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.summary-item,
.info-item {
padding: 16px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 6px;
}
.summary-item span,
.info-item span {
color: #64748b;
font-size: 13px;
}
.summary-item strong,
.info-item strong {
color: #0f172a;
font-size: 17px;
}
.content-card {
padding: 28px;
display: flex;
flex-direction: column;
gap: 18px;
}
.loading-card,
.error-card {
align-items: center;
justify-content: center;
text-align: center;
min-height: 240px;
}
.large-icon {
font-size: 44px;
}
.large-icon.success {
color: #16a34a;
}
.large-icon.error {
color: #dc2626;
}
.spinning {
animation: spin 1s linear infinite;
}
.status-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-radius: 14px;
font-size: 14px;
}
.status-banner.info {
background: #eff6ff;
color: #1d4ed8;
}
.status-banner.success {
background: #f0fdf4;
color: #166534;
}
.status-banner.warning {
background: #fff7ed;
color: #c2410c;
}
.status-banner.error {
background: #fef2f2;
color: #b91c1c;
}
.guide-grid,
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.guide-figure {
margin: 0;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
}
.guide-figure img {
width: 100%;
display: block;
}
.guide-figure figcaption {
padding: 10px 12px 14px;
color: #475569;
font-size: 13px;
}
.qr-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 22px;
border-radius: 18px;
background: linear-gradient(180deg, #eff6ff 0%, #f8fafc 100%);
border: 1px solid #dbeafe;
}
.qr-code {
width: 280px;
height: 280px;
padding: 12px;
background: #ffffff;
border-radius: 18px;
}
.action-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.warning-line {
color: #b45309;
}
.meta-line {
font-size: 13px;
}
.result-header {
display: flex;
align-items: flex-start;
gap: 14px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
.claim-page {
padding: 16px;
}
.header-card,
.summary-card,
.content-card {
max-width: 100%;
}
.header-card {
flex-direction: column;
align-items: flex-start;
}
.summary-card,
.guide-grid,
.info-grid {
grid-template-columns: 1fr;
}
.qr-code {
width: min(280px, 100%);
height: auto;
}
.action-row {
width: 100%;
flex-direction: column;
}
}
</style>