Files
order_site/apps/frontend/src/views/claim/KuaishouCloudClaimView.vue
T
2026-05-03 20:58:18 +08:00

488 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { showError, showSuccess } from '@/lib/feedback'
import { fetchClaimDetail, verifyKuaishouCloudClaimTicket } from '@/services/claim'
import type { ClaimDetailData } from '@/types/claim'
const props = defineProps<{
token: string
}>()
const loading = ref(true)
const submitting = ref(false)
const errorMessage = ref('')
const detail = ref<ClaimDetailData | null>(null)
const ticketCode = ref('')
const expandedPanels = ref(['guide'])
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 isCompleted = computed(() => flow.value?.consume.status === 'success' || task.value?.status === 'completed')
const isWaitingSupport = computed(
() => flow.value?.ticket.status === 'verified' && !isCompleted.value && flow.value?.dispatch.status !== 'success',
)
const canSubmitTicket = computed(() => {
if (!flow.value?.binding.bindUrl) {
return false
}
return !['completed', 'manual_review', 'closed', 'expired'].includes(String(task.value?.status || '').trim())
})
const bindButtonLabel = computed(() => {
if (flow.value?.ticket.status === 'verified') {
return '继续打开绑定链接'
}
return '校验后继续绑定'
})
const pageStatusText = computed(() => {
if (isCompleted.value) {
return '流程已完成'
}
if (flow.value?.consume.status === 'failed') {
return '核销异常'
}
if (flow.value?.dispatch.status === 'success') {
return '已发货'
}
if (flow.value?.ticket.status === 'verified') {
return '等待客服确认'
}
if (flow.value?.binding.prepareStatus === 'ready') {
return '等待填写核销码'
}
return '等待系统准备'
})
async function loadDetail(options: { silent?: boolean } = {}) {
if (!options.silent) {
loading.value = true
}
errorMessage.value = ''
try {
const response = await fetchClaimDetail(props.token)
detail.value = response.data
if (!ticketCode.value && response.data.kuaishouCloudFulfillment?.ticket.code) {
ticketCode.value = response.data.kuaishouCloudFulfillment.ticket.code
}
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,
})
detail.value = response.data
ticketCode.value = response.data.kuaishouCloudFulfillment?.ticket.code || normalizedTicketCode
showSuccess('核销码校验成功,正在跳转绑定页')
syncPolling()
const bindUrl = String(response.data.kuaishouCloudFulfillment?.binding.bindUrl || '').trim()
if (bindUrl) {
window.setTimeout(() => {
window.location.assign(bindUrl)
}, 600)
}
} catch (error) {
showError(error instanceof Error ? error.message : '核销码校验失败')
} finally {
submitting.value = false
}
}
function openBindUrl() {
const bindUrl = String(flow.value?.binding.bindUrl || '').trim()
if (!bindUrl) {
showError('当前还没有可用绑定链接')
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) && !['completed', 'manual_review', 'failed', 'closed', 'expired'].includes(taskStatus)
if (!shouldPoll) {
stopPolling()
return
}
if (pollTimer) {
return
}
pollTimer = window.setInterval(() => {
void loadDetail({ silent: true })
}, 4000)
}
onMounted(() => {
void loadDetail()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<template>
<main class="kuaishou-claim-page">
<section class="hero-card">
<div class="hero-copy">
<span class="eyebrow">快手小店客户领取</span>
<h1>先获取核销码,再继续绑定游戏资源</h1>
<p>
订单 {{ order?.platformOrderId || '-' }} · 商品 {{ orderItem?.skuName || '-' }}。
你只需要按图片指引找到快手小店核销码,提交成功后页面会自动跳转到绑定链接。
</p>
</div>
<div class="hero-status">
<span class="status-label">当前状态</span>
<strong>{{ pageStatusText }}</strong>
</div>
</section>
<div v-if="loading" class="placeholder-card">
<strong>页面加载中</strong>
<p>正在检查领取链接和当前任务进度。</p>
</div>
<div v-else-if="errorMessage" class="placeholder-card error-card">
<strong>无法继续处理</strong>
<p>{{ errorMessage }}</p>
</div>
<template v-else-if="detail && flow">
<section class="info-grid">
<article class="info-card">
<span>核销码校验</span>
<strong>{{ flow.ticket.status === 'verified' ? '已通过' : '待提交' }}</strong>
<p>{{ flow.ticket.code || '还没有提交核销码' }}</p>
</article>
<article class="info-card">
<span>绑定链接</span>
<strong>{{ flow.binding.prepareStatus === 'ready' ? '已准备' : '准备中' }}</strong>
<p>{{ flow.binding.bindPreparedAt || '系统准备完成后即可继续' }}</p>
</article>
<article class="info-card">
<span>发货状态</span>
<strong>{{ flow.dispatch.status === 'success' ? '已发货' : '等待客服' }}</strong>
<p>{{ flow.dispatch.dispatchAt || flow.dispatch.note || '客服确认绑定信息无误后会执行发货' }}</p>
</article>
<article class="info-card">
<span>快手核销</span>
<strong>{{ flow.consume.status === 'success' ? '已完成' : flow.consume.status === 'failed' ? '异常' : '待完成' }}</strong>
<p>{{ flow.consume.errorMessage || flow.consume.consumedAt || '发货和退号完成后会自动核销' }}</p>
</article>
</section>
<section class="form-card">
<div class="card-head">
<div>
<h2>第 1 步:复制并粘贴快手小店核销码</h2>
<p>核销码会使用这笔订单对应的快手店铺进行校验,校验通过后才会继续绑定。</p>
</div>
<el-button
v-if="flow.ticket.status === 'verified'"
round
type="primary"
plain
@click="openBindUrl"
>
{{ bindButtonLabel }}
</el-button>
</div>
<label class="field-block">
<span>核销码</span>
<el-input
v-model="ticketCode"
maxlength="120"
placeholder="请粘贴快手小店里的核销码"
:disabled="submitting || !canSubmitTicket"
@keyup.enter="submitTicket"
/>
</label>
<div class="action-row">
<el-button
round
type="primary"
:loading="submitting"
:disabled="!canSubmitTicket"
@click="submitTicket"
>
{{ bindButtonLabel }}
</el-button>
<span class="action-tip">
校验成功后会自动跳转到绑定页;绑定完成后请等待客服继续处理。
</span>
</div>
</section>
<section v-if="isWaitingSupport || flow.dispatch.status === 'success' || isCompleted" class="state-card">
<h2>第 2 步:完成绑定后等待客服处理</h2>
<p v-if="isWaitingSupport">核销码已经通过校验。请在外部绑定页完成绑定,然后等待客服确认绑定信息并执行发货。</p>
<p v-else-if="flow.dispatch.status === 'success' && !isCompleted">客服已经执行发货,系统正在进行退号和快手核销收口。</p>
<p v-else>所有步骤都已经完成,没有检测到异常,可以回到快手小店查看最终状态。</p>
</section>
<section class="guide-card">
<div class="card-head">
<div>
<h2>图文指引</h2>
<p>如果你不确定核销码在哪里,可以展开下面的步骤图对照操作。</p>
</div>
</div>
<el-collapse v-model="expandedPanels">
<el-collapse-item name="guide" title="展开核销码获取步骤">
<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>
</section>
</template>
<div v-else class="placeholder-card error-card">
<strong>流程数据不完整</strong>
<p>当前领取链接没有查到可用的快手 Cloud 履约上下文,请联系客服处理。</p>
</div>
</main>
</template>
<style scoped>
.kuaishou-claim-page {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(255, 182, 132, 0.24), transparent 28%),
radial-gradient(circle at bottom right, rgba(98, 176, 255, 0.18), transparent 34%),
linear-gradient(180deg, #fff7ef 0%, #f6fbff 100%);
color: #1f2937;
}
.hero-card,
.placeholder-card,
.form-card,
.state-card,
.guide-card,
.info-card {
border-radius: 28px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
}
.hero-card {
max-width: 1180px;
margin: 0 auto 18px;
padding: 28px;
display: grid;
grid-template-columns: minmax(0, 1.4fr) 240px;
gap: 20px;
align-items: center;
}
.eyebrow {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
background: rgba(251, 146, 60, 0.14);
color: #b45309;
font-weight: 700;
letter-spacing: 0.04em;
}
.hero-copy h1,
.form-card h2,
.state-card h2,
.guide-card h2 {
margin: 14px 0 10px;
color: #0f172a;
}
.hero-copy p,
.card-head p,
.state-card p,
.placeholder-card p,
.action-tip {
color: #475569;
line-height: 1.7;
}
.hero-status {
padding: 20px;
border-radius: 24px;
background: linear-gradient(180deg, #101828 0%, #1f3f5b 100%);
color: #f8fafc;
}
.hero-status strong {
display: block;
margin-top: 8px;
font-size: 26px;
}
.status-label,
.info-card span,
.field-block span {
color: #94a3b8;
font-size: 13px;
}
.placeholder-card,
.form-card,
.state-card,
.guide-card {
max-width: 1180px;
margin: 0 auto 18px;
padding: 24px;
}
.error-card {
border-color: rgba(239, 68, 68, 0.18);
}
.info-grid {
max-width: 1180px;
margin: 0 auto 18px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.info-card {
padding: 20px;
}
.info-card strong {
display: block;
margin: 8px 0 10px;
font-size: 19px;
color: #0f172a;
}
.info-card p {
margin: 0;
color: #475569;
line-height: 1.6;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.field-block {
display: grid;
gap: 8px;
margin-top: 18px;
}
.action-row {
margin-top: 16px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.state-card {
border-style: dashed;
}
.guide-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 8px;
}
.guide-figure {
margin: 0;
padding: 14px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
border: 1px solid rgba(148, 163, 184, 0.14);
}
.guide-figure img {
display: block;
width: 100%;
border-radius: 16px;
}
.guide-figure figcaption {
margin-top: 10px;
color: #475569;
font-weight: 600;
text-align: center;
}
@media (max-width: 980px) {
.kuaishou-claim-page {
padding: 16px;
}
.hero-card,
.info-grid,
.guide-grid {
grid-template-columns: 1fr;
}
.card-head {
flex-direction: column;
}
}
</style>