前面的ok了, 绑定与角色

This commit is contained in:
yml
2026-05-03 20:58:18 +08:00
parent dd2192b6ae
commit 0f0253bffe
30 changed files with 1900 additions and 409 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

+2
View File
@@ -15,6 +15,8 @@ declare module 'vue' {
AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
+1 -1
View File
@@ -508,7 +508,7 @@ export function dispatchAdminTaskKuaishouCloudFulfillment(
taskId: number | string,
payload: {
ticketCode?: string
},
} = {},
) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/dispatch`, payload)
}
+9
View File
@@ -21,6 +21,15 @@ export function fetchClaimSessionSummary(token: string) {
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}/session/summary`)
}
export function verifyKuaishouCloudClaimTicket(
token: string,
payload: {
ticketCode: string
},
) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/verify-ticket`, payload)
}
export function refreshClaimSession(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/session/refresh`, {})
}
+10
View File
@@ -212,6 +212,8 @@ export interface AdminKhhaoOrderPreview {
platformLabel: string
platformOrderId: string
shopId: string
kuaishouShopId: string
khhaoShopId: string
shopName: string
itemId: string
itemTitle: string
@@ -321,6 +323,8 @@ export interface AdminFulfillmentBindingConfigItem {
provider: string
platform: string
shopId: string
shopName: string
khhaoShopId: string
skuCode: string
skuName: string
profileKey: string
@@ -693,6 +697,10 @@ export interface AdminTaskDetail {
ticket: {
code: string
capturedAt: string | null
status: string
verifiedAt: string | null
goodsTitle: string
leftCount: number
}
binding: {
prepareStatus: string
@@ -729,6 +737,8 @@ export interface AdminTaskDetail {
status: string
shopId: string
autoConsumeEnabled: boolean
consumedAt: string | null
errorMessage: string
}
notes: string
}
+42
View File
@@ -6,6 +6,11 @@ export type ClaimTaskStatus =
| 'pending_payment'
| 'paid'
| 'waiting_inventory'
| 'pending_binding_prepare'
| 'waiting_binding'
| 'dispatched_pending_return'
| 'completed'
| 'failed'
| 'cdk_reserved'
| 'link_generated'
| 'claimed'
@@ -58,12 +63,49 @@ export interface ClaimResultInfo {
screenshotUrl: string
}
export interface ClaimKuaishouCloudFlowInfo {
flowType: 'kuaishou_cloud'
shopId: string
shopName: string
guideImages: string[]
ticket: {
code: string
status: string
capturedAt: string | null
verifiedAt: string | null
goodsTitle: string
leftCount: number
}
binding: {
prepareStatus: string
bindUrl: string
bindPreparedAt: string | null
vnPhone: string
}
dispatch: {
status: string
dispatchAt: string | null
note: string
}
returnNumber: {
status: string
returnedAt: string | null
}
consume: {
status: string
consumedAt: string | null
errorMessage: string
}
}
export interface ClaimDetailData {
tokenStatus: ClaimTokenStatus
claimUrl: string
flowType: 'tencent_claim' | 'kuaishou_cloud' | (string & {})
task: ClaimTaskInfo
order: ClaimOrderInfo
orderItem: ClaimOrderItemInfo
session: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null
kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null
result: ClaimResultInfo | null
}
@@ -5,6 +5,7 @@ import { showSuccess } from '@/lib/feedback'
import {
fetchAdminFulfillmentBindingConfigs,
fetchAdminKhhaoSourceConfig,
fetchAdminKuaishouEticketSourceConfig,
queryAdminKhhaoOrders,
lookupAdminFulfillmentBindingOrder,
saveAdminFulfillmentBindingConfigs,
@@ -14,6 +15,7 @@ import type {
AdminFulfillmentLookupItem,
AdminFulfillmentLookupResult,
AdminKhhaoOrderPreview,
AdminKuaishouEticketShopConfigItem,
AdminObservedProductItem,
} from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
@@ -24,6 +26,8 @@ type EditableBinding = {
provider: string
platform: string
shopId: string
shopName: string
khhaoShopId: string
skuCode: string
skuName: string
profileKey: string
@@ -39,6 +43,8 @@ type SaveBindingPayload = {
provider: string
platform: string
shopId: string
shopName: string
khhaoShopId: string
skuCode: string
skuName: string
profileKey: string
@@ -54,7 +60,7 @@ type SaveBindingPayload = {
}
}
type ValidationField = 'skuCode' | 'match' | 'profileKey'
type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId' | 'khhaoShopId'
type ValidationState = {
bindingId: string
@@ -75,6 +81,7 @@ const errorMessage = ref('')
const filePath = ref('')
const bindings = ref<EditableBinding[]>([])
const observedProducts = ref<AdminObservedProductItem[]>([])
const kuaishouShopOptions = ref<AdminKuaishouEticketShopConfigItem[]>([])
const lookupLoading = ref(false)
const lookupErrorMessage = ref('')
const lookupResult = ref<AdminFulfillmentLookupResult | null>(null)
@@ -119,8 +126,10 @@ const pendingObservedCount = computed(() => observedProducts.value.filter((item)
type ImportableProductCandidate = Pick<
AdminObservedProductItem,
'provider' | 'platform' | 'shopId' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName'
>
'provider' | 'platform' | 'shopId' | 'shopName' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName'
> & {
khhaoShopId?: string
}
function createEmptyBinding(): EditableBinding {
return {
@@ -128,6 +137,8 @@ function createEmptyBinding(): EditableBinding {
provider: 'agiso',
platform: '',
shopId: '',
shopName: '',
khhaoShopId: '',
skuCode: '',
skuName: '',
profileKey: 'manual_review',
@@ -141,11 +152,14 @@ function createEmptyBinding(): EditableBinding {
}
function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding {
return {
const matchedShop = resolveKuaishouShopById(item.shopId) || resolveKuaishouShopByName(item.shopName || '')
const binding = {
id: crypto.randomUUID(),
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shopId,
shopId: matchedShop?.shopId || item.shopId,
shopName: item.shopName || matchedShop?.kshopName || '',
khhaoShopId: item.khhaoShopId || '',
skuCode: item.skuCode,
skuName: item.skuName,
profileKey: item.profileKey || 'manual_review',
@@ -156,6 +170,9 @@ function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBi
externalSkuName: item.match.externalSkuName,
resolvedSkuName: String(item.match.config?.resolvedSkuName || ''),
}
syncBindingShopSelection(binding)
return binding
}
function hasMatchCondition(item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>) {
@@ -203,16 +220,64 @@ function getBindingStatusLabel(binding: EditableBinding) {
}
function getBindingSummary(binding: EditableBinding) {
const shopLabel = isKhhaoKuaishouBinding(binding)
? [
binding.shopName.trim() || binding.shopId.trim() || '未选快手店铺',
binding.khhaoShopId.trim() ? `khhao#${binding.khhaoShopId.trim()}` : '',
].filter(Boolean).join(' / ')
: (binding.shopName.trim() || binding.shopId.trim() || '跨店铺')
const parts = [
binding.provider.trim() || 'agiso',
binding.platform.trim() || '未选平台',
binding.shopId.trim() || '跨店铺',
shopLabel,
binding.profileKey.trim() || 'manual_review',
]
return parts.join(' / ')
}
function isKhhaoKuaishouBinding(binding: Pick<EditableBinding, 'provider' | 'platform'>) {
return binding.provider.trim() === 'khhao' && binding.platform.trim() === 'kuaishou'
}
function resolveKuaishouShopById(shopId: string) {
const normalizedShopId = shopId.trim()
return kuaishouShopOptions.value.find((item) => item.shopId === normalizedShopId) || null
}
function resolveKuaishouShopByName(shopName: string) {
const normalizedShopName = shopName.trim()
return kuaishouShopOptions.value.find((item) => item.kshopName === normalizedShopName) || null
}
function getKuaishouShopOptionLabel(item: AdminKuaishouEticketShopConfigItem) {
return item.kshopName.trim()
? `${item.kshopName} (${item.shopId})`
: item.shopId
}
function syncBindingShopSelection(binding: EditableBinding) {
if (!isKhhaoKuaishouBinding(binding)) {
return
}
const matchedById = resolveKuaishouShopById(binding.shopId)
if (matchedById) {
binding.shopId = matchedById.shopId
binding.shopName = matchedById.kshopName
return
}
const matchedByName = resolveKuaishouShopByName(binding.shopName)
if (matchedByName) {
binding.shopId = matchedByName.shopId
binding.shopName = matchedByName.kshopName
return
}
binding.shopId = ''
}
async function loadConfigs() {
if (!hasAdminRole('admin')) {
loading.value = false
@@ -223,11 +288,13 @@ async function loadConfigs() {
errorMessage.value = ''
try {
const [response, khhaoSourceResponse] = await Promise.all([
const [response, khhaoSourceResponse, kuaishouSourceResponse] = await Promise.all([
fetchAdminFulfillmentBindingConfigs(),
fetchAdminKhhaoSourceConfig(),
fetchAdminKuaishouEticketSourceConfig(),
])
filePath.value = response.data.filePath
kuaishouShopOptions.value = kuaishouSourceResponse.data.source.shops.filter((item) => item.enabled !== false)
const nextBindings = response.data.bindings.map(mapEditableBinding)
bindings.value = nextBindings
collapsedBindingIds.value = buildCollapsedIds(nextBindings)
@@ -259,11 +326,13 @@ function removeBinding(id: string) {
}
function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding {
return {
const binding = {
id: crypto.randomUUID(),
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shopId,
shopName: item.shopName,
khhaoShopId: item.khhaoShopId || '',
skuCode: '',
skuName: item.externalSkuName,
profileKey: 'manual_review',
@@ -274,6 +343,9 @@ function createBindingFromProductCandidate(item: ImportableProductCandidate): Ed
externalSkuName: item.externalSkuName,
resolvedSkuName: item.externalSkuName,
}
syncBindingShopSelection(binding)
return binding
}
function importObservedProduct(item: ImportableProductCandidate) {
@@ -298,7 +370,11 @@ function importKhhaoProduct(item: AdminKhhaoOrderPreview) {
importObservedProduct({
provider: item.provider || 'khhao',
platform: item.platform || 'unknown',
shopId: item.shopId,
shopId: isKhhaoKuaishouBinding({ provider: item.provider || 'khhao', platform: item.platform || 'unknown' })
? (item.kuaishouShopId || resolveKuaishouShopByName(item.shopName)?.shopId || '')
: item.shopId,
shopName: item.shopName,
khhaoShopId: item.khhaoShopId || item.shopId,
externalSkuCode: item.skuCode,
externalItemId: item.itemId,
externalSkuName: item.itemTitle,
@@ -314,10 +390,14 @@ function isKhhaoProductImported(platformOrderId: string) {
}
function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
syncBindingShopSelection(item)
return {
provider: item.provider.trim() || 'agiso',
platform: item.platform.trim(),
shopId: item.shopId.trim(),
shopName: item.shopName.trim(),
khhaoShopId: item.khhaoShopId.trim(),
skuCode: item.skuCode.trim(),
skuName: item.skuName.trim(),
profileKey: item.profileKey.trim() || 'manual_review',
@@ -338,6 +418,8 @@ function hasBindingContent(item: SaveBindingPayload) {
return Boolean(
item.platform
|| item.shopId
|| item.shopName
|| item.khhaoShopId
|| item.skuCode
|| item.skuName
|| item.match.externalSkuCode
@@ -353,6 +435,24 @@ function hasBindingContent(item: SaveBindingPayload) {
function resolveBindingValidationState(item: SaveBindingPayload, index: number, bindingId: string): ValidationState {
const label = `${index + 1} 条规则`
const provider = item.provider.trim() || 'agiso'
const platform = item.platform.trim()
if (provider === 'khhao' && platform === 'kuaishou' && (!item.shopId || !item.shopName)) {
return {
bindingId,
field: 'shopId',
message: `${label} 需要选择已配置的快手官方店铺`,
}
}
if (provider === 'khhao' && platform === 'kuaishou' && !item.khhaoShopId) {
return {
bindingId,
field: 'khhaoShopId',
message: `${label} 需要填写 khhao 店铺 ID`,
}
}
if (!item.skuCode) {
return {
@@ -397,6 +497,8 @@ async function focusValidationTarget(state: ValidationState) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
const selectors: Record<ValidationField, string> = {
khhaoShopId: '[data-field="khhaoShopId"]',
shopId: '[data-field="shopId"]',
skuCode: '[data-field="skuCode"]',
match: '[data-field="externalSkuCode"]',
profileKey: '[data-field="profileKey"]',
@@ -426,6 +528,16 @@ function isFieldInvalid(bindingId: string, field: ValidationField | 'externalSku
return validationState.value.field === field
}
function handleBindingProviderPlatformChange(binding: EditableBinding) {
syncBindingShopSelection(binding)
clearValidationState()
}
function handleKuaishouShopChange(binding: EditableBinding) {
syncBindingShopSelection(binding)
clearValidationState()
}
async function saveConfigs() {
const normalizedBindings = bindings.value.map(normalizeBindingForSave)
const nonEmptyBindings = normalizedBindings.filter(hasBindingContent)
@@ -649,18 +761,90 @@ onMounted(loadConfigs)
<div v-else class="binding-grid">
<label class="field-block">
<span>渠道</span>
<input v-model="binding.provider" class="text-input" placeholder="agiso" @input="clearValidationState" />
<input
v-model="binding.provider"
class="text-input"
placeholder="agiso"
@input="handleBindingProviderPlatformChange(binding)"
/>
</label>
<label class="field-block">
<span>平台</span>
<input v-model="binding.platform" class="text-input" placeholder="xianyu / taobao / pdd" @input="clearValidationState" />
<input
v-model="binding.platform"
class="text-input"
placeholder="xianyu / taobao / pdd"
@input="handleBindingProviderPlatformChange(binding)"
/>
</label>
<label class="field-block">
<span>店铺 ID</span>
<input v-model="binding.shopId" class="text-input" placeholder="留空表示跨店铺通用" @input="clearValidationState" />
</label>
<template v-if="isKhhaoKuaishouBinding(binding)">
<label class="field-block">
<span>khhao 店铺 ID</span>
<input
v-model="binding.khhaoShopId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'khhaoShopId') }]"
data-field="khhaoShopId"
placeholder="例如 12 / 13 / 14"
@input="clearValidationState"
/>
<small class="field-help">这里填 khhao 系统里的店铺编号专门用于同步订单命中规则</small>
</label>
<label class="field-block">
<span>快手店铺</span>
<select
v-model="binding.shopId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
data-field="shopId"
@change="handleKuaishouShopChange(binding)"
>
<option value="">请选择已配置的快手官方店铺</option>
<option
v-for="shop in kuaishouShopOptions"
:key="shop.shopId"
:value="shop.shopId"
>
{{ getKuaishouShopOptionLabel(shop) }}
</option>
</select>
<small class="field-help">这里会直接写入快手官方店铺 ID不再使用 khhao 内部店铺编号</small>
</label>
<label class="field-block">
<span>快手店铺名</span>
<input
:value="binding.shopName || ''"
class="text-input text-input--readonly"
placeholder="选择店铺后自动带出"
readonly
/>
</label>
</template>
<template v-else>
<label class="field-block">
<span>店铺 ID</span>
<input
v-model="binding.shopId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'shopId') }]"
data-field="shopId"
placeholder="留空表示跨店铺通用"
@input="clearValidationState"
/>
</label>
<label class="field-block">
<span>店铺名称</span>
<input
v-model="binding.shopName"
class="text-input"
placeholder="用于保存和识别店铺"
@input="clearValidationState"
/>
</label>
</template>
<label class="field-block">
<span>优先级</span>
@@ -825,7 +1009,8 @@ onMounted(loadConfigs)
<td>
<div class="cell-stack">
<strong>{{ item.shopName || item.shopId || '-' }}</strong>
<span class="cell-subtle">ID: {{ item.shopId || '-' }}</span>
<span class="cell-subtle">khhao ID: {{ item.khhaoShopId || item.shopId || '-' }}</span>
<span v-if="item.kuaishouShopId" class="cell-subtle">快手 ID: {{ item.kuaishouShopId }}</span>
</div>
</td>
<td>
@@ -1379,6 +1564,11 @@ onMounted(loadConfigs)
box-shadow: 0 0 0 3px rgba(254, 226, 226, 0.95);
}
.text-input--readonly {
color: #475467;
background: rgba(148, 163, 184, 0.08);
}
.binding-inline-error {
margin: 10px 0 0;
color: #b42318;
@@ -36,9 +36,6 @@ const manualDispatchForm = reactive({
deliveredCredential: '',
resultMessage: '',
})
const kuaishouCloudForm = reactive({
ticketCode: '',
})
const canManageTaskLifecycle = computed(() => hasAdminRole('operator'))
const canCloseTasks = computed(() => hasAdminRole('support'))
const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData))
@@ -74,7 +71,6 @@ async function loadDetail() {
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
detail.value = response.data
syncManualDispatchForm(response.data)
syncKuaishouCloudForm(response.data)
await loadScreenshotPreview(response.data)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
@@ -142,10 +138,6 @@ function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
manualDispatchForm.resultMessage = taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || ''
}
function syncKuaishouCloudForm(taskDetail: AdminTaskDetail) {
kuaishouCloudForm.ticketCode = taskDetail.kuaishouCloudFulfillment?.ticket.code || ''
}
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
if (!detail.value) {
return
@@ -186,9 +178,7 @@ async function submitKuaishouCloudDispatch() {
}
await runAction(
() => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId, {
ticketCode: kuaishouCloudForm.ticketCode,
}),
() => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
'已完成绑定确认并发货',
`确认客户已经完成绑定,并立即为任务 ${detail.value.task.taskNo} 执行发货吗?这个动作会把“确认绑定完成”和“发货”合并为一步。`,
)
@@ -611,17 +601,28 @@ onBeforeUnmount(clearScreenshotPreview)
</article>
</div>
<div class="flow-form-card">
<label class="field-block">
<span>客户提供的卡券号</span>
<el-input
v-model="kuaishouCloudForm.ticketCode"
:disabled="!canManageTaskLifecycle"
maxlength="120"
placeholder="客户下单后提供的卡券号,可在发货时一并记录"
/>
</label>
<p class="manual-hint">确认绑定完成并发货已经合并为一个按钮不再拆成两步</p>
<div class="flow-grid">
<article class="flow-block">
<h4>客户领取页</h4>
<div class="flow-meta-list">
<div><span>客户链接</span><strong>{{ claimUrl || '-' }}</strong></div>
<div><span>Token 状态</span><strong>{{ detail.claimToken?.status || '-' }}</strong></div>
<div><span>首次提交</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.capturedAt) }}</strong></div>
<div><span>校验时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.ticket.verifiedAt) }}</strong></div>
</div>
<p class="manual-hint">客户会先在这个页面查看图文指引并提交核销码校验通过后才会自动跳转绑定链接</p>
</article>
<article class="flow-block">
<h4>核销码状态</h4>
<div class="flow-meta-list">
<div><span>核销码</span><strong>{{ kuaishouCloudFlow.ticket.code || '-' }}</strong></div>
<div><span>校验状态</span><strong>{{ kuaishouCloudFlow.ticket.status || '-' }}</strong></div>
<div><span>商品标题</span><strong>{{ kuaishouCloudFlow.ticket.goodsTitle || '-' }}</strong></div>
<div><span>剩余次数</span><strong>{{ kuaishouCloudFlow.ticket.leftCount || 0 }}</strong></div>
</div>
<p class="manual-hint">确认绑定完成并发货已经合并为一个按钮只有客户提交并校验过核销码后才能继续</p>
</article>
</div>
<div class="flow-grid">
@@ -643,6 +644,8 @@ onBeforeUnmount(clearScreenshotPreview)
<div><span>自动退号</span><strong>{{ kuaishouCloudFlow.returnNumber.autoReturnEnabled ? '开启' : '关闭' }}</strong></div>
<div><span>自动核销</span><strong>{{ kuaishouCloudFlow.consume.autoConsumeEnabled ? '开启' : '关闭' }}</strong></div>
<div><span>核销店铺</span><strong>{{ kuaishouCloudFlow.consume.shopId || '-' }}</strong></div>
<div><span>核销时间</span><strong>{{ formatAdminDateTime(kuaishouCloudFlow.consume.consumedAt) }}</strong></div>
<div><span>核销异常</span><strong>{{ kuaishouCloudFlow.consume.errorMessage || '-' }}</strong></div>
<div><span>备注</span><strong>{{ kuaishouCloudFlow.notes || '-' }}</strong></div>
</div>
</article>
@@ -0,0 +1,357 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
import { useClaimPage } from '@/composables/useClaimPage'
const route = useRoute()
const token = computed(() => String(route.params.token || '').trim())
const {
detailLoading,
roleConfirmLoading,
sessionLoading,
redeemLoading,
loginType,
loginTypeLabel,
loginTabs,
task,
order,
orderItem,
hasSession,
qrImage,
qrFigureStyle,
qrPreviewWidth,
statusLabel,
session,
sessionNotice,
roleFacts,
resultFacts,
roleConfirmed,
roleReady,
canConfirmRole,
canRedeem,
redeemBlockedReason,
redeemButtonLabel,
initButtonLabel,
scanInstruction,
screenshotEmptyTitle,
screenshotEmptyMessage,
screenshotUrl,
showScreenshot,
createSessionFlow,
reloadSessionPage,
closeSessionFlow,
switchLoginType,
confirmRoleNow,
redeemNow,
handleQrImageLoad,
} = useClaimPage(token.value)
const qrPreviewVisible = ref(false)
const screenshotPreviewVisible = ref(false)
const currentLoginTabLabel = computed(
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
)
const helperText = computed(() => {
if (!task.value) {
return '正在加载领取任务'
}
if (task.value.requiresSupportReview) {
return `订单 ${order.value?.platformOrderId || '-'} · 登录后请联系人工客服继续确认和兑换`
}
return `订单 ${order.value?.platformOrderId || '-'} · 任务 ${task.value.taskNo}`
})
</script>
<template>
<main class="claim-page">
<section class="workspace">
<div class="page-toolbar">
<div>
<div class="brand-chip">订单领取</div>
<p class="helper-copy">{{ helperText }}</p>
</div>
<div class="toolbar-actions">
<div class="status-badge">{{ statusLabel }}</div>
<RouterLink class="back-link" to="/tx/browser">调试页</RouterLink>
</div>
</div>
<section class="order-card">
<div class="order-meta">
<article>
<span>订单号</span>
<strong>{{ order?.platformOrderId || '-' }}</strong>
</article>
<article>
<span>商品</span>
<strong>{{ orderItem?.skuName || '-' }}</strong>
</article>
<article>
<span>数量</span>
<strong>{{ orderItem?.quantity || 0 }}</strong>
</article>
<article>
<span>任务号</span>
<strong>{{ task?.taskNo || '-' }}</strong>
</article>
</div>
</section>
<div v-if="detailLoading" class="empty-block loading-block">
<strong>领取信息加载中</strong>
<p>正在校验链接并同步任务状态</p>
</div>
<template v-else>
<div class="layout-grid">
<TencentAuthCard
:current-login-tab-label="currentLoginTabLabel"
:has-session="hasSession"
:init-button-label="initButtonLabel"
:login-tabs="loginTabs"
:login-type="loginType"
:login-type-label="loginTypeLabel"
:qr-figure-style="qrFigureStyle"
:qr-image="qrImage"
:scan-instruction="scanInstruction"
:session-status="session?.status || ''"
:session-id="session?.sessionId || ''"
:session-loading="sessionLoading"
:session-notice="sessionNotice"
@create-session="createSessionFlow"
@close-session="closeSessionFlow"
@open-qr-preview="qrPreviewVisible = true"
@qr-image-load="handleQrImageLoad"
@reload-session="reloadSessionPage"
@switch-login-type="switchLoginType"
/>
<div class="control-column">
<TencentRedeemPanel
:can-redeem="canRedeem"
:can-confirm-role="canConfirmRole"
confirm-action-label="确认当前角色并继续"
:confirm-action-loading="roleConfirmLoading"
:confirm-action-visible="!roleConfirmed && !task?.requiresSupportReview"
:confirm-readonly="true"
:hide-redeem-form="true"
:login-type-label="loginTypeLabel"
:max-attempts="6"
:redeem-blocked-reason="redeemBlockedReason"
:redeem-button-label="redeemButtonLabel"
:redeem-code="''"
:redeem-loading="redeemLoading"
:role-confirmed="roleConfirmed"
:role-facts="roleFacts"
:role-ready="roleReady"
:status-label="statusLabel"
@confirm-role="confirmRoleNow"
@redeem="redeemNow"
@update:max-attempts="() => undefined"
@update:redeem-code="() => undefined"
@update:role-confirmed="() => undefined"
/>
</div>
</div>
<TencentResultPanel
:result-facts="resultFacts"
:screenshot-empty-message="screenshotEmptyMessage"
:screenshot-empty-title="screenshotEmptyTitle"
:screenshot-url="screenshotUrl"
:show-screenshot="showScreenshot"
@open-screenshot-preview="screenshotPreviewVisible = true"
/>
</template>
</section>
<el-dialog
v-model="qrPreviewVisible"
align-center
class="preview-dialog"
:width="qrPreviewWidth"
>
<div class="qr-preview-body">
<img
class="preview-image qr-preview-image"
:src="qrImage"
:alt="`Claim ${loginTypeLabel} QR Preview`"
/>
</div>
</el-dialog>
<el-dialog
v-model="screenshotPreviewVisible"
align-center
class="preview-dialog"
width="1080px"
>
<img class="preview-image" :src="screenshotUrl" alt="Claim Screenshot Preview" />
</el-dialog>
</main>
</template>
<style scoped>
.claim-page {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(73, 136, 255, 0.12), transparent 32%),
radial-gradient(circle at top right, rgba(87, 194, 150, 0.12), transparent 28%),
linear-gradient(180deg, #f4f8fc 0%, #eef4fb 100%);
}
.workspace {
max-width: 1240px;
margin: 0 auto;
display: grid;
gap: 18px;
}
.page-toolbar,
.toolbar-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand-chip,
.status-badge,
.back-link {
display: inline-flex;
align-items: center;
min-height: 42px;
padding: 0 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(86, 108, 138, 0.12);
color: #27405e;
box-shadow: 0 12px 32px rgba(30, 49, 78, 0.08);
}
.brand-chip {
font-weight: 800;
}
.status-badge {
font-weight: 700;
}
.back-link {
text-decoration: none;
}
.helper-copy {
margin: 8px 0 0;
color: #64748b;
}
.order-card {
padding: 18px 22px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow: 0 22px 60px rgba(30, 41, 59, 0.08);
}
.order-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.order-meta article {
padding: 14px 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9), rgba(241, 245, 249, 0.92));
border: 1px solid rgba(148, 163, 184, 0.16);
}
.order-meta span {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
.order-meta strong {
font-size: 16px;
color: #0f172a;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.95fr);
gap: 18px;
align-items: start;
}
.control-column {
display: grid;
gap: 18px;
}
.empty-block {
padding: 28px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.18);
color: #475569;
}
.loading-block strong {
display: block;
margin-bottom: 6px;
color: #0f172a;
}
.preview-dialog :deep(.el-dialog__body) {
padding: 0;
}
.preview-image {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.qr-preview-body {
display: grid;
place-items: center;
padding: 20px;
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
}
.qr-preview-image {
max-width: 100%;
max-height: min(78vh, 920px);
}
@media (max-width: 980px) {
.claim-page {
padding: 16px;
}
.page-toolbar,
.toolbar-actions {
flex-direction: column;
align-items: stretch;
}
.order-meta,
.layout-grid {
grid-template-columns: 1fr;
}
}
</style>
+55 -340
View File
@@ -1,374 +1,89 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
import { useClaimPage } from '@/composables/useClaimPage'
import { fetchClaimDetail } from '@/services/claim'
import ClaimTencentView from './ClaimTencentView.vue'
import KuaishouCloudClaimView from './KuaishouCloudClaimView.vue'
const route = useRoute()
const token = computed(() => String(route.params.token || '').trim())
const loading = ref(true)
const errorMessage = ref('')
const flowType = ref('tencent_claim')
const {
detailLoading,
roleConfirmLoading,
sessionLoading,
redeemLoading,
loginType,
loginTypeLabel,
loginTabs,
task,
order,
orderItem,
hasSession,
qrImage,
qrFigureStyle,
qrPreviewWidth,
statusLabel,
session,
sessionNotice,
roleFacts,
resultFacts,
roleConfirmed,
roleReady,
canConfirmRole,
canRedeem,
redeemBlockedReason,
redeemButtonLabel,
initButtonLabel,
scanInstruction,
screenshotEmptyTitle,
screenshotEmptyMessage,
screenshotUrl,
showScreenshot,
createSessionFlow,
reloadSessionPage,
closeSessionFlow,
switchLoginType,
confirmRoleNow,
redeemNow,
handleQrImageLoad,
} = useClaimPage(token.value)
async function bootstrap() {
loading.value = true
errorMessage.value = ''
const qrPreviewVisible = ref(false)
const screenshotPreviewVisible = ref(false)
try {
const response = await fetchClaimDetail(token.value)
flowType.value = String(response.data.flowType || response.data.task.executorKey || 'tencent_claim').trim()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取领取信息失败'
} finally {
loading.value = false
}
}
const currentLoginTabLabel = computed(
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
const isKuaishouCloudFlow = computed(
() => flowType.value === 'kuaishou_cloud' || flowType.value === 'kuaishou_ct_assisted',
)
const helperText = computed(() => {
if (!task.value) {
return '正在加载领取任务'
}
if (task.value.requiresSupportReview) {
return `订单 ${order.value?.platformOrderId || '-'} · 登录后请联系人工客服继续确认和兑换`
}
return `订单 ${order.value?.platformOrderId || '-'} · 任务 ${task.value.taskNo}`
onMounted(() => {
void bootstrap()
})
</script>
<template>
<main class="claim-page">
<section class="workspace">
<div class="page-toolbar">
<div>
<div class="brand-chip">订单领取</div>
<p class="helper-copy">{{ helperText }}</p>
</div>
<div class="toolbar-actions">
<div class="status-badge">{{ statusLabel }}</div>
<RouterLink class="back-link" to="/tx/browser">调试页</RouterLink>
</div>
</div>
<section class="order-card">
<div class="order-meta">
<article>
<span>订单号</span>
<strong>{{ order?.platformOrderId || '-' }}</strong>
</article>
<article>
<span>商品</span>
<strong>{{ orderItem?.skuName || '-' }}</strong>
</article>
<article>
<span>数量</span>
<strong>{{ orderItem?.quantity || 0 }}</strong>
</article>
<article>
<span>任务号</span>
<strong>{{ task?.taskNo || '-' }}</strong>
</article>
</div>
</section>
<div v-if="detailLoading" class="empty-block loading-block">
<strong>领取信息加载中</strong>
<p>正在校验链接并同步任务状态</p>
</div>
<template v-else>
<div class="layout-grid">
<TencentAuthCard
:current-login-tab-label="currentLoginTabLabel"
:has-session="hasSession"
:init-button-label="initButtonLabel"
:login-tabs="loginTabs"
:login-type="loginType"
:login-type-label="loginTypeLabel"
:qr-figure-style="qrFigureStyle"
:qr-image="qrImage"
:scan-instruction="scanInstruction"
:session-status="session?.status || ''"
:session-id="session?.sessionId || ''"
:session-loading="sessionLoading"
:session-notice="sessionNotice"
@create-session="createSessionFlow"
@close-session="closeSessionFlow"
@open-qr-preview="qrPreviewVisible = true"
@qr-image-load="handleQrImageLoad"
@reload-session="reloadSessionPage"
@switch-login-type="switchLoginType"
/>
<div class="control-column">
<TencentRedeemPanel
:can-redeem="canRedeem"
:can-confirm-role="canConfirmRole"
confirm-action-label="确认当前角色并继续"
:confirm-action-loading="roleConfirmLoading"
:confirm-action-visible="!roleConfirmed && !task?.requiresSupportReview"
:confirm-readonly="true"
:hide-redeem-form="true"
:login-type-label="loginTypeLabel"
:max-attempts="6"
:redeem-blocked-reason="redeemBlockedReason"
:redeem-button-label="redeemButtonLabel"
:redeem-code="''"
:redeem-loading="redeemLoading"
:role-confirmed="roleConfirmed"
:role-facts="roleFacts"
:role-ready="roleReady"
:status-label="statusLabel"
@confirm-role="confirmRoleNow"
@redeem="redeemNow"
@update:max-attempts="() => undefined"
@update:redeem-code="() => undefined"
@update:role-confirmed="() => undefined"
/>
</div>
</div>
<TencentResultPanel
:result-facts="resultFacts"
:screenshot-empty-message="screenshotEmptyMessage"
:screenshot-empty-title="screenshotEmptyTitle"
:screenshot-url="screenshotUrl"
:show-screenshot="showScreenshot"
@open-screenshot-preview="screenshotPreviewVisible = true"
/>
</template>
<main v-if="loading" class="claim-shell loading-shell">
<section class="shell-card">
<strong>页面加载中</strong>
<p>正在识别领取流程类型</p>
</section>
<el-dialog
v-model="qrPreviewVisible"
align-center
class="preview-dialog"
:width="qrPreviewWidth"
>
<div class="qr-preview-body">
<img
class="preview-image qr-preview-image"
:src="qrImage"
:alt="`Claim ${loginTypeLabel} QR Preview`"
/>
</div>
</el-dialog>
<el-dialog
v-model="screenshotPreviewVisible"
align-center
class="preview-dialog"
width="1080px"
>
<img class="preview-image" :src="screenshotUrl" alt="Claim Screenshot Preview" />
</el-dialog>
</main>
<main v-else-if="errorMessage" class="claim-shell error-shell">
<section class="shell-card">
<strong>无法打开领取页</strong>
<p>{{ errorMessage }}</p>
</section>
</main>
<KuaishouCloudClaimView v-else-if="isKuaishouCloudFlow" :token="token" />
<ClaimTencentView v-else />
</template>
<style scoped>
.claim-page {
.claim-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(73, 136, 255, 0.12), transparent 32%),
radial-gradient(circle at top right, rgba(87, 194, 150, 0.12), transparent 28%),
linear-gradient(180deg, #f4f8fc 0%, #eef4fb 100%);
radial-gradient(circle at top left, rgba(74, 222, 128, 0.12), transparent 28%),
linear-gradient(180deg, #f8fafc 0%, #eef4fb 100%);
}
.workspace {
max-width: 1240px;
margin: 0 auto;
display: grid;
gap: 18px;
}
.page-toolbar,
.toolbar-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand-chip,
.status-badge,
.back-link {
display: inline-flex;
align-items: center;
min-height: 42px;
padding: 0 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(86, 108, 138, 0.12);
color: #27405e;
box-shadow: 0 12px 32px rgba(30, 49, 78, 0.08);
}
.brand-chip {
font-weight: 800;
}
.status-badge {
font-weight: 700;
}
.back-link {
text-decoration: none;
}
.helper-copy {
margin: 8px 0 0;
color: #64748b;
}
.order-card {
padding: 18px 22px;
.shell-card {
width: min(520px, 100%);
padding: 28px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
}
.order-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.order-meta article {
padding: 14px;
border-radius: 16px;
background: #f7f9fc;
}
.order-meta span {
display: block;
font-size: 12px;
color: #708096;
}
.order-meta strong {
display: block;
margin-top: 6px;
color: #1f324a;
word-break: break-word;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 420px) minmax(0, 1fr);
gap: 18px;
align-items: stretch;
}
.control-column {
display: grid;
gap: 14px;
}
.empty-block {
min-height: 220px;
border-radius: 18px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
text-align: center;
padding: 24px;
background: rgba(255, 255, 255, 0.92);
border: 1px dashed #d5dfec;
}
.loading-block strong {
color: #2c4058;
font-size: 16px;
}
.loading-block p {
margin: 8px 0 0;
color: #6b7a91;
line-height: 1.7;
}
.preview-image {
.shell-card strong {
display: block;
width: 100%;
height: auto;
margin-bottom: 10px;
color: #0f172a;
}
:deep(.preview-dialog .el-dialog) {
border-radius: 28px;
overflow: hidden;
}
:deep(.preview-dialog .el-dialog__body) {
padding: 0;
background: #f5f7fb;
}
.qr-preview-body {
display: grid;
place-items: center;
padding: 24px;
}
.qr-preview-image {
width: auto;
max-width: 100%;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
@media (max-width: 1140px) {
.layout-grid,
.order-meta {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.claim-page {
padding: 16px;
}
.page-toolbar,
.toolbar-actions {
flex-direction: column;
align-items: flex-start;
}
.shell-card p {
margin: 0;
color: #475569;
}
</style>
@@ -0,0 +1,487 @@
<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>