[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 ClaimTencentView from './ClaimTencentView.vue'
|
||||
import KuaishouCloudClaimView from './KuaishouCloudClaimView.vue'
|
||||
import KuaishouCloudClaimView from './kuaishou-cloud/KuaishouCloudClaimView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const token = computed(() => String(route.params.token || '').trim())
|
||||
@@ -63,8 +63,7 @@ onMounted(() => {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(74, 222, 128, 0.12), transparent 28%),
|
||||
background: radial-gradient(circle at top left, rgba(74, 222, 128, 0.12), transparent 28%),
|
||||
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