[Snow Team] claim-refactor: auto-commit work
This commit is contained in:
@@ -5,7 +5,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { fetchClaimDetail } from '@/services/claim'
|
import { fetchClaimDetail } from '@/services/claim'
|
||||||
|
|
||||||
import ClaimTencentView from './ClaimTencentView.vue'
|
import ClaimTencentView from './ClaimTencentView.vue'
|
||||||
import KuaishouCloudClaimView from './KuaishouCloudClaimView.vue'
|
import KuaishouCloudClaimView from './kuaishou-cloud/KuaishouCloudClaimView.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const token = computed(() => String(route.params.token || '').trim())
|
const token = computed(() => String(route.params.token || '').trim())
|
||||||
@@ -63,8 +63,7 @@ onMounted(() => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background:
|
background: radial-gradient(circle at top left, rgba(74, 222, 128, 0.12), transparent 28%),
|
||||||
radial-gradient(circle at top left, rgba(74, 222, 128, 0.12), transparent 28%),
|
|
||||||
linear-gradient(180deg, #f8fafc 0%, #eef4fb 100%);
|
linear-gradient(180deg, #f8fafc 0%, #eef4fb 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,839 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CircleClose, Loading } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import ClaimBindingStep from './components/ClaimBindingStep.vue'
|
||||||
|
import ClaimConfirmStep from './components/ClaimConfirmStep.vue'
|
||||||
|
import ClaimHeaderCard from './components/ClaimHeaderCard.vue'
|
||||||
|
import ClaimResultStep from './components/ClaimResultStep.vue'
|
||||||
|
import ClaimSummaryCard from './components/ClaimSummaryCard.vue'
|
||||||
|
import ClaimTicketStep from './components/ClaimTicketStep.vue'
|
||||||
|
import { useKuaishouCloudClaim } from './composables/useKuaishouCloudClaim'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
token: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const claim = useKuaishouCloudClaim(() => props.token)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="claim-page">
|
||||||
|
<ClaimHeaderCard
|
||||||
|
:order="claim.order.value"
|
||||||
|
:order-item="claim.orderItem.value"
|
||||||
|
:current-step="claim.currentStep.value"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="claim.loading.value" class="content-card loading-card">
|
||||||
|
<el-icon class="large-icon spinning"><Loading /></el-icon>
|
||||||
|
<p>正在读取当前领取进度...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="claim.errorMessage.value" class="content-card error-card">
|
||||||
|
<el-icon class="large-icon error"><CircleClose /></el-icon>
|
||||||
|
<p>{{ claim.errorMessage.value }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="claim.detail.value && claim.flow.value">
|
||||||
|
<ClaimSummaryCard
|
||||||
|
:progress-text="claim.progressText.value"
|
||||||
|
:role-name="claim.roleName.value"
|
||||||
|
:role-id="claim.roleId.value"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClaimTicketStep
|
||||||
|
v-if="claim.currentStep.value === 1"
|
||||||
|
:ticket-code="claim.ticketCode.value"
|
||||||
|
:can-submit-ticket="claim.canSubmitTicket.value"
|
||||||
|
:submitting="claim.submitting.value"
|
||||||
|
:is-binding-preparing="claim.isBindingPreparing.value"
|
||||||
|
:flow="claim.flow.value"
|
||||||
|
@update:ticket-code="claim.ticketCode.value = $event"
|
||||||
|
@submit-ticket="claim.submitTicket()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClaimBindingStep
|
||||||
|
v-else-if="claim.currentStep.value === 2"
|
||||||
|
:qr-code-data-url="claim.qrCodeDataUrl.value"
|
||||||
|
:is-binding-prepared="claim.isBindingPrepared.value"
|
||||||
|
:is-binding-preparing="claim.isBindingPreparing.value"
|
||||||
|
:is-role-ready="claim.isRoleReady.value"
|
||||||
|
:refreshing-role="claim.refreshingRole.value"
|
||||||
|
:confirming-role="claim.confirmingRole.value"
|
||||||
|
:flow="claim.flow.value"
|
||||||
|
:format-date-time="claim.formatDateTime"
|
||||||
|
@open-bind-url="claim.openBindUrl($event)"
|
||||||
|
@refresh-role="claim.refreshRole()"
|
||||||
|
@confirm-role="claim.confirmRole()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClaimConfirmStep
|
||||||
|
v-else-if="claim.currentStep.value === 3"
|
||||||
|
:role-name="claim.roleName.value"
|
||||||
|
:role-id="claim.roleId.value"
|
||||||
|
:order-item="claim.orderItem.value"
|
||||||
|
:redeeming="claim.redeeming.value"
|
||||||
|
@confirm-redeem="claim.confirmRedeem()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClaimResultStep
|
||||||
|
v-else
|
||||||
|
:result-title="claim.resultTitle.value"
|
||||||
|
:result-description="claim.resultDescription.value"
|
||||||
|
:flow="claim.flow.value"
|
||||||
|
:order="claim.order.value"
|
||||||
|
:order-item="claim.orderItem.value"
|
||||||
|
:role-name="claim.roleName.value"
|
||||||
|
:role-id="claim.roleId.value"
|
||||||
|
:format-date-time="claim.formatDateTime"
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.claim-page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Loading, RefreshRight } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import type { ClaimKuaishouCloudFlowInfo } from '@/types/claim'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
qrCodeDataUrl: string
|
||||||
|
isBindingPrepared: boolean
|
||||||
|
isBindingPreparing: boolean
|
||||||
|
isRoleReady: boolean
|
||||||
|
refreshingRole: boolean
|
||||||
|
confirmingRole: boolean
|
||||||
|
flow: ClaimKuaishouCloudFlowInfo
|
||||||
|
formatDateTime: (value: string | null) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
openBindUrl: [useCurrentPage?: boolean]
|
||||||
|
refreshRole: []
|
||||||
|
confirmRole: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section 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="$emit('openBindUrl', false)">
|
||||||
|
新窗口打开绑定页
|
||||||
|
</el-button>
|
||||||
|
<el-button size="large" plain @click="$emit('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="$emit('refreshRole')">
|
||||||
|
<el-icon><RefreshRight /></el-icon>
|
||||||
|
刷新角色信息
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="confirmingRole"
|
||||||
|
:disabled="!isBindingPrepared || !isRoleReady"
|
||||||
|
@click="$emit('confirmRole')"
|
||||||
|
>
|
||||||
|
我已完成绑定,下一步
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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);
|
||||||
|
padding: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-line {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-line {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.content-card {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
width: min(280px, 100%);
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Warning } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import type { ClaimOrderItemInfo } from '@/types/claim'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
roleName: string
|
||||||
|
roleId: string
|
||||||
|
orderItem: ClaimOrderItemInfo | null
|
||||||
|
redeeming: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
confirmRedeem: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section 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="$emit('confirmRedeem')">
|
||||||
|
确认兑换
|
||||||
|
</el-button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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);
|
||||||
|
padding: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.warning {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.content-card {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ClaimOrderInfo, ClaimOrderItemInfo } from '@/types/claim'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
order: ClaimOrderInfo | null
|
||||||
|
orderItem: ClaimOrderItemInfo | null
|
||||||
|
currentStep: number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="header-card">
|
||||||
|
<div class="header-copy">
|
||||||
|
<span class="eyebrow">快手 Cloud 客户领取</span>
|
||||||
|
<h1>{{ orderItem?.skuName || '商品领取' }}</h1>
|
||||||
|
<p>订单号:{{ order?.platformOrderId || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="step-indicator">
|
||||||
|
<div
|
||||||
|
v-for="step in 4"
|
||||||
|
:key="step"
|
||||||
|
class="step-dot"
|
||||||
|
:class="{ active: currentStep >= step }"
|
||||||
|
>
|
||||||
|
{{ step }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-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);
|
||||||
|
padding: 24px 28px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-copy h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-copy p {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.header-card {
|
||||||
|
max-width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CircleCheck } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import type { ClaimKuaishouCloudFlowInfo, ClaimOrderInfo, ClaimOrderItemInfo } from '@/types/claim'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
resultTitle: string
|
||||||
|
resultDescription: string
|
||||||
|
flow: ClaimKuaishouCloudFlowInfo
|
||||||
|
order: ClaimOrderInfo | null
|
||||||
|
orderItem: ClaimOrderItemInfo | null
|
||||||
|
roleName: string
|
||||||
|
roleId: string
|
||||||
|
formatDateTime: (value: string | null) => string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section 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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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);
|
||||||
|
padding: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-icon {
|
||||||
|
font-size: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-icon.success {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.content-card {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
progressText: string
|
||||||
|
roleName: string
|
||||||
|
roleId: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.summary-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);
|
||||||
|
padding: 18px 22px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.summary-card {
|
||||||
|
max-width: 100%;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Clock } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import type { ClaimKuaishouCloudFlowInfo } from '@/types/claim'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
ticketCode: string
|
||||||
|
canSubmitTicket: boolean
|
||||||
|
submitting: boolean
|
||||||
|
isBindingPreparing: boolean
|
||||||
|
flow: ClaimKuaishouCloudFlowInfo
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:ticketCode': [value: string]
|
||||||
|
submitTicket: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="content-card">
|
||||||
|
<h2>第 1 步:提交核销码</h2>
|
||||||
|
<p class="muted">
|
||||||
|
请先从快手小店复制核销码。验证通过后,系统会自动准备 Cloud
|
||||||
|
绑定资源,不需要再让客服手工点"准备绑定资源"。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
:model-value="ticketCode"
|
||||||
|
placeholder="粘贴快手核销码"
|
||||||
|
size="large"
|
||||||
|
:disabled="!canSubmitTicket"
|
||||||
|
@update:model-value="$emit('update:ticketCode', $event)"
|
||||||
|
@keyup.enter="$emit('submitTicket')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!canSubmitTicket"
|
||||||
|
@click="$emit('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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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);
|
||||||
|
padding: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.content-card {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import { showError, showSuccess } from '@/lib/feedback'
|
||||||
|
import {
|
||||||
|
confirmKuaishouCloudClaimRole,
|
||||||
|
fetchClaimDetail,
|
||||||
|
redeemKuaishouCloudClaim,
|
||||||
|
verifyKuaishouCloudClaimTicket,
|
||||||
|
} from '@/services/claim'
|
||||||
|
import type { ClaimDetailData } from '@/types/claim'
|
||||||
|
|
||||||
|
export function useKuaishouCloudClaim(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
|
||||||
|
|
||||||
|
// ── derived data ────────────────────────────────────────
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// ── computed status flags ───────────────────────────────
|
||||||
|
|
||||||
|
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 '你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── actions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
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(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(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(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(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')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── polling ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── utility ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── lifecycle ───────────────────────────────────────────
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadDetail()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
loading,
|
||||||
|
submitting,
|
||||||
|
refreshingRole,
|
||||||
|
confirmingRole,
|
||||||
|
redeeming,
|
||||||
|
errorMessage,
|
||||||
|
detail,
|
||||||
|
ticketCode,
|
||||||
|
qrCodeDataUrl,
|
||||||
|
// derived data
|
||||||
|
flow,
|
||||||
|
order,
|
||||||
|
orderItem,
|
||||||
|
task,
|
||||||
|
// computed status
|
||||||
|
roleName,
|
||||||
|
roleId,
|
||||||
|
isTicketVerified,
|
||||||
|
isBindUrlExpired,
|
||||||
|
isBindingPrepared,
|
||||||
|
isBindingPreparing,
|
||||||
|
canEnterBindingStep,
|
||||||
|
isRoleReady,
|
||||||
|
isRoleConfirmed,
|
||||||
|
isDispatched,
|
||||||
|
isCompleted,
|
||||||
|
hasRedeemResult,
|
||||||
|
canSubmitTicket,
|
||||||
|
currentStep,
|
||||||
|
progressText,
|
||||||
|
resultTitle,
|
||||||
|
resultDescription,
|
||||||
|
// actions
|
||||||
|
loadDetail,
|
||||||
|
submitTicket,
|
||||||
|
refreshRole,
|
||||||
|
confirmRole,
|
||||||
|
confirmRedeem,
|
||||||
|
openBindUrl,
|
||||||
|
// utility
|
||||||
|
formatDateTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user