[Snow Team] claim-refactor: auto-commit work

This commit is contained in:
yml2213
2026-05-17 09:45:55 +08:00
parent a77198b218
commit bc15fcc349
10 changed files with 1309 additions and 842 deletions
+2 -3
View File
@@ -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,
}
}