添加快手 Cloud 换绑角色功能
This commit is contained in:
@@ -66,6 +66,13 @@ export function refreshAdminTaskKuaishouCloudRoleInfo(taskId: number | string) {
|
||||
)
|
||||
}
|
||||
|
||||
export function rebindAdminTaskKuaishouCloudRole(taskId: number | string) {
|
||||
return apiPost<AdminTaskActionResponse>(
|
||||
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/rebind-role`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function dispatchAdminTaskKuaishouCloudFulfillment(
|
||||
taskId: number | string,
|
||||
payload: {
|
||||
|
||||
@@ -18,6 +18,10 @@ export function confirmKuaishouCloudClaimRole(token: string) {
|
||||
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {})
|
||||
}
|
||||
|
||||
export function rebindKuaishouCloudClaimRole(token: string) {
|
||||
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/rebind-role`, {})
|
||||
}
|
||||
|
||||
export function redeemKuaishouCloudClaim(token: string) {
|
||||
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface AdminTaskOperations {
|
||||
canCompleteManualDispatch: boolean
|
||||
canPrepareKuaishouCloudFulfillment: boolean
|
||||
canRefreshKuaishouCloudRoleInfo: boolean
|
||||
canRebindKuaishouCloudRole: boolean
|
||||
canDispatchKuaishouCloudFulfillment: boolean
|
||||
canReturnKuaishouCloudFulfillment: boolean
|
||||
canViewSensitiveTaskData: boolean
|
||||
@@ -169,6 +170,35 @@ export interface AdminTaskDetail {
|
||||
consumedAt: string | null
|
||||
errorMessage: string
|
||||
}
|
||||
rebind: {
|
||||
currentAttempt: number
|
||||
history: Array<{
|
||||
attempt: number
|
||||
source: string
|
||||
requestedAt: string | null
|
||||
requestedBy: Record<string, unknown> | null
|
||||
status: string
|
||||
errorMessage: string
|
||||
oldBinding: {
|
||||
vnKey: string
|
||||
vnId: number
|
||||
vnPhone: string
|
||||
bindUrl: string
|
||||
bindPreparedAt: string | null
|
||||
bindExpiresAt: string | null
|
||||
roleName: string
|
||||
roleId: string
|
||||
}
|
||||
newBinding: null | {
|
||||
vnKey: string
|
||||
vnId: number
|
||||
vnPhone: string
|
||||
bindUrl: string
|
||||
bindPreparedAt: string | null
|
||||
bindExpiresAt: string | null
|
||||
}
|
||||
}>
|
||||
}
|
||||
notes: string
|
||||
}
|
||||
manualDispatch: null | {
|
||||
|
||||
@@ -119,6 +119,10 @@ export interface ClaimKuaishouCloudFlowInfo {
|
||||
consumedAt: string | null
|
||||
errorMessage: string
|
||||
}
|
||||
rebind?: {
|
||||
currentAttempt: number
|
||||
history: Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClaimDetailData {
|
||||
|
||||
@@ -1,134 +1,135 @@
|
||||
export function formatStatusWithRaw(
|
||||
status: string,
|
||||
labelMap: Record<string, string> = {}
|
||||
) {
|
||||
const normalized = String(status || "").trim();
|
||||
export function formatStatusWithRaw(status: string, labelMap: Record<string, string> = {}) {
|
||||
const normalized = String(status || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return "-";
|
||||
return '-'
|
||||
}
|
||||
|
||||
const key = normalized.toLowerCase();
|
||||
const label = labelMap[key];
|
||||
const key = normalized.toLowerCase()
|
||||
const label = labelMap[key]
|
||||
|
||||
if (!label || label === normalized) {
|
||||
return normalized;
|
||||
return normalized
|
||||
}
|
||||
|
||||
return `${label} (${normalized})`;
|
||||
return `${label} (${normalized})`
|
||||
}
|
||||
|
||||
export function formatAuditAction(action: string) {
|
||||
const labelMap: Record<string, string> = {
|
||||
admin_user_created: "创建后台用户",
|
||||
admin_user_role_updated: "修改用户角色",
|
||||
admin_user_status_updated: "修改用户状态",
|
||||
admin_user_password_reset: "重置用户密码",
|
||||
task_regenerate_claim_link: "重发领取链接",
|
||||
task_closed: "关闭任务",
|
||||
task_mark_manual_review: "转人工处理",
|
||||
task_complete_manual_dispatch: "完成人工履约",
|
||||
platform_shop_config_updated: "更新店铺配置",
|
||||
platform_fulfillment_config_updated: "更新履约配置",
|
||||
};
|
||||
admin_user_created: '创建后台用户',
|
||||
admin_user_role_updated: '修改用户角色',
|
||||
admin_user_status_updated: '修改用户状态',
|
||||
admin_user_password_reset: '重置用户密码',
|
||||
task_regenerate_claim_link: '重发领取链接',
|
||||
task_kuaishou_cloud_rebind_role: '换绑角色',
|
||||
task_closed: '关闭任务',
|
||||
task_mark_manual_review: '转人工处理',
|
||||
task_complete_manual_dispatch: '完成人工履约',
|
||||
platform_shop_config_updated: '更新店铺配置',
|
||||
platform_fulfillment_config_updated: '更新履约配置',
|
||||
}
|
||||
|
||||
return formatStatusWithRaw(action, labelMap);
|
||||
return formatStatusWithRaw(action, labelMap)
|
||||
}
|
||||
|
||||
export function formatAuditTargetType(targetType: string) {
|
||||
const labelMap: Record<string, string> = {
|
||||
admin_user: "后台用户",
|
||||
task: "交付任务",
|
||||
platform_config: "平台配置",
|
||||
};
|
||||
admin_user: '后台用户',
|
||||
task: '交付任务',
|
||||
platform_config: '平台配置',
|
||||
}
|
||||
|
||||
return formatStatusWithRaw(targetType, labelMap);
|
||||
return formatStatusWithRaw(targetType, labelMap)
|
||||
}
|
||||
|
||||
export function formatTaskEventType(eventType: string) {
|
||||
const labelMap: Record<string, string> = {
|
||||
manual_dispatch_completed: "人工履约已回写",
|
||||
claim_redeem_code_used: "兑换码已使用",
|
||||
claim_redeem_code_invalid: "兑换码错误",
|
||||
claim_redeem_completed: "兑换链路完成",
|
||||
};
|
||||
manual_dispatch_completed: '人工履约已回写',
|
||||
claim_redeem_code_used: '兑换码已使用',
|
||||
claim_redeem_code_invalid: '兑换码错误',
|
||||
claim_redeem_completed: '兑换链路完成',
|
||||
kuaishou_cloud_rebind_requested: '发起角色换绑',
|
||||
kuaishou_cloud_rebind_old_number_returned: '换绑旧号已退还',
|
||||
kuaishou_cloud_rebind_prepared: '换绑资源已准备',
|
||||
kuaishou_cloud_rebind_failed: '换绑失败',
|
||||
}
|
||||
|
||||
return formatStatusWithRaw(eventType, labelMap);
|
||||
return formatStatusWithRaw(eventType, labelMap)
|
||||
}
|
||||
|
||||
export function formatRedeemOutcomeLabel(value: string) {
|
||||
const normalized = String(value || "").trim();
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
switch (normalized) {
|
||||
case "success":
|
||||
return "兑换成功";
|
||||
case "code_used":
|
||||
return "兑换码已使用";
|
||||
case "code_invalid":
|
||||
return "兑换码错误";
|
||||
case "captcha_rejected":
|
||||
return "验证码错误";
|
||||
case "failed":
|
||||
return "兑换失败";
|
||||
case 'success':
|
||||
return '兑换成功'
|
||||
case 'code_used':
|
||||
return '兑换码已使用'
|
||||
case 'code_invalid':
|
||||
return '兑换码错误'
|
||||
case 'captcha_rejected':
|
||||
return '验证码错误'
|
||||
case 'failed':
|
||||
return '兑换失败'
|
||||
default:
|
||||
return normalized || "-";
|
||||
return normalized || '-'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatRedeemResolutionStatus(value: string) {
|
||||
return value === "success"
|
||||
? "已完成"
|
||||
: value === "failed"
|
||||
? "失败收口"
|
||||
: value || "-";
|
||||
return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : value || '-'
|
||||
}
|
||||
|
||||
export function formatKuaishouRoleInfoLabel(value: string) {
|
||||
const normalized = String(value || "").trim();
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
switch (normalized) {
|
||||
case "name":
|
||||
return "角色名称";
|
||||
case "rid":
|
||||
return "角色 ID";
|
||||
case 'name':
|
||||
return '角色名称'
|
||||
case 'rid':
|
||||
return '角色 ID'
|
||||
default:
|
||||
return normalized || "-";
|
||||
return normalized || '-'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTaskEventPayload(payload: Record<string, unknown>) {
|
||||
const parts = [
|
||||
payload.outcome ? `结果 ${payload.outcome}` : "",
|
||||
payload.resultCode ? `代码 ${payload.resultCode}` : "",
|
||||
payload.resultMessage ? `说明 ${payload.resultMessage}` : "",
|
||||
payload.codeMasked ? `凭据 ${payload.codeMasked}` : "",
|
||||
payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : "",
|
||||
payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : "",
|
||||
payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : "",
|
||||
payload.vnId ? `虚拟号 #${payload.vnId}` : "",
|
||||
payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : "",
|
||||
payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : "",
|
||||
payload.sendType ? `发送类型 ${payload.sendType}` : "",
|
||||
payload.note ? `备注 ${payload.note}` : "",
|
||||
payload.outcome ? `结果 ${payload.outcome}` : '',
|
||||
payload.resultCode ? `代码 ${payload.resultCode}` : '',
|
||||
payload.resultMessage ? `说明 ${payload.resultMessage}` : '',
|
||||
payload.codeMasked ? `凭据 ${payload.codeMasked}` : '',
|
||||
payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : '',
|
||||
payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : '',
|
||||
payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : '',
|
||||
payload.vnId ? `虚拟号 #${payload.vnId}` : '',
|
||||
payload.oldVnId ? `旧虚拟号 #${payload.oldVnId}` : '',
|
||||
payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : '',
|
||||
payload.oldVnPhoneMasked ? `旧号码 ${payload.oldVnPhoneMasked}` : '',
|
||||
payload.oldRoleName ? `旧角色 ${payload.oldRoleName}` : '',
|
||||
payload.oldRoleId ? `旧角色ID ${payload.oldRoleId}` : '',
|
||||
payload.attempt ? `第 ${payload.attempt} 次` : '',
|
||||
payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : '',
|
||||
payload.sendType ? `发送类型 ${payload.sendType}` : '',
|
||||
payload.note ? `备注 ${payload.note}` : '',
|
||||
payload.previousOutcome
|
||||
? `切换原因 ${formatRedeemOutcomeLabel(
|
||||
String(payload.previousOutcome || "")
|
||||
)}`
|
||||
: "",
|
||||
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : "",
|
||||
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : "",
|
||||
payload.trigger ? `触发 ${payload.trigger}` : "",
|
||||
payload.reason ? `原因 ${payload.reason}` : "",
|
||||
payload.purchaseTriggered === true ? "已触发购买" : "",
|
||||
payload.usedKnapsack === true ? "使用背包库存" : "",
|
||||
payload.responseStatus ? `HTTP ${payload.responseStatus}` : "",
|
||||
payload.requestId ? `请求 ${payload.requestId}` : "",
|
||||
payload.errorMessage ? `错误 ${payload.errorMessage}` : "",
|
||||
].filter(Boolean);
|
||||
? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}`
|
||||
: '',
|
||||
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
|
||||
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
|
||||
payload.trigger ? `触发 ${payload.trigger}` : '',
|
||||
payload.reason ? `原因 ${payload.reason}` : '',
|
||||
payload.purchaseTriggered === true ? '已触发购买' : '',
|
||||
payload.usedKnapsack === true ? '使用背包库存' : '',
|
||||
payload.responseStatus ? `HTTP ${payload.responseStatus}` : '',
|
||||
payload.requestId ? `请求 ${payload.requestId}` : '',
|
||||
payload.errorMessage ? `错误 ${payload.errorMessage}` : '',
|
||||
].filter(Boolean)
|
||||
|
||||
if (parts.length > 0) {
|
||||
return parts.join(" · ");
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
return JSON.stringify(payload || {});
|
||||
return JSON.stringify(payload || {})
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const adminAuditActionOptions = [
|
||||
{ label: '修改用户角色', value: 'admin_user_role_updated' },
|
||||
{ label: '修改用户状态', value: 'admin_user_status_updated' },
|
||||
{ label: '重置用户密码', value: 'admin_user_password_reset' },
|
||||
{ label: '重发领取链接', value: 'task_regenerate_claim_link' },
|
||||
{ label: '换绑角色', value: 'task_kuaishou_cloud_rebind_role' },
|
||||
{ label: '关闭任务', value: 'task_closed' },
|
||||
{ label: '转人工处理', value: 'task_mark_manual_review' },
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ import { onMounted } from 'vue'
|
||||
import {
|
||||
closeAdminTask,
|
||||
markAdminTaskManualReview,
|
||||
regenerateAdminTaskClaimLink,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
retryAdminTask,
|
||||
} from '@/services/admin'
|
||||
|
||||
@@ -83,11 +83,11 @@ onMounted(loadDetail)
|
||||
`确认重试任务 ${detail!.task.taskNo} 吗?`,
|
||||
)
|
||||
"
|
||||
@regenerate-claim-link="
|
||||
@rebind-role="
|
||||
runAction(
|
||||
() => regenerateAdminTaskClaimLink(detail!.task.taskId),
|
||||
'领取链接已重新生成',
|
||||
`确认重新生成任务 ${detail!.task.taskNo} 的领取链接吗?旧链接会失效。`,
|
||||
() => rebindAdminTaskKuaishouCloudRole(detail!.task.taskId),
|
||||
'角色换绑资源已准备完成',
|
||||
`确认为任务 ${detail!.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||
)
|
||||
"
|
||||
@prepare-kuaishou-cloud="submitKuaishouCloudPrepare"
|
||||
|
||||
@@ -1,38 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useAdminAction } from "@/composables/useAdminAction";
|
||||
import { useAdminListPage } from "@/composables/useAdminListPage";
|
||||
import AdminPageHeader from "@/components/admin/AdminPageHeader.vue";
|
||||
import { useAdminAction } from '@/composables/useAdminAction'
|
||||
import { useAdminListPage } from '@/composables/useAdminListPage'
|
||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||
import {
|
||||
closeAdminTask,
|
||||
fetchAdminTasks,
|
||||
markAdminTaskManualReview,
|
||||
regenerateAdminTaskClaimLink,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
retryAdminTask,
|
||||
} from "@/services/admin";
|
||||
import type { AdminTaskListItem } from "@/types/admin";
|
||||
import { hasAdminRole } from "@/utils/admin-auth";
|
||||
import { adminTaskStatusOptions } from "@/utils/admin-options";
|
||||
import { formatAdminDateTime } from "@/utils/admin-time";
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskListItem } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { adminTaskStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const status = ref("");
|
||||
const taskNo = ref("");
|
||||
const platformOrderId = ref("");
|
||||
const roleId = ref("");
|
||||
const skuCode = ref("");
|
||||
const dateFrom = ref("");
|
||||
const dateTo = ref("");
|
||||
const lastClaimUrl = ref("");
|
||||
const status = ref('')
|
||||
const taskNo = ref('')
|
||||
const platformOrderId = ref('')
|
||||
const roleId = ref('')
|
||||
const skuCode = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const lastClaimUrl = ref('')
|
||||
|
||||
const canManageTaskLifecycle = hasAdminRole("operator");
|
||||
const canCloseTasks = hasAdminRole("support");
|
||||
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||
const canCloseTasks = hasAdminRole('support')
|
||||
|
||||
type AdminTaskActionKey =
|
||||
| "retry"
|
||||
| "regenerate_claim_link"
|
||||
| "manual_review"
|
||||
| "close";
|
||||
type AdminTaskActionKey = 'retry' | 'rebind_role' | 'manual_review' | 'close'
|
||||
|
||||
const {
|
||||
loading,
|
||||
@@ -41,7 +37,7 @@ const {
|
||||
pagination,
|
||||
loadPage: loadTasks,
|
||||
} = useAdminListPage<AdminTaskListItem>({
|
||||
defaultErrorMessage: "读取任务列表失败",
|
||||
defaultErrorMessage: '读取任务列表失败',
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchAdminTasks({
|
||||
page,
|
||||
@@ -54,141 +50,144 @@ const {
|
||||
dateFrom: dateFrom.value.trim(),
|
||||
dateTo: dateTo.value.trim(),
|
||||
}),
|
||||
});
|
||||
})
|
||||
|
||||
const { actionLoadingId, runAction } = useAdminAction();
|
||||
const { actionLoadingId, runAction } = useAdminAction()
|
||||
|
||||
function handleActionCommand(
|
||||
actionKey: AdminTaskActionKey,
|
||||
item: AdminTaskListItem
|
||||
) {
|
||||
if (actionKey === "retry") {
|
||||
function handleActionCommand(actionKey: AdminTaskActionKey, item: AdminTaskListItem) {
|
||||
if (actionKey === 'retry') {
|
||||
void runAction({
|
||||
id: item.taskId,
|
||||
action: async () => {
|
||||
const response = await retryAdminTask(item.taskId);
|
||||
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl;
|
||||
const response = await retryAdminTask(item.taskId)
|
||||
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl
|
||||
},
|
||||
successMessage: "任务已重试",
|
||||
successMessage: '任务已重试',
|
||||
confirmText: `确认重试任务 ${item.taskNo} 吗?`,
|
||||
onAfterAction: async () => {
|
||||
await loadTasks();
|
||||
await loadTasks()
|
||||
},
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
if (actionKey === "regenerate_claim_link") {
|
||||
if (actionKey === 'rebind_role') {
|
||||
void runAction({
|
||||
id: item.taskId,
|
||||
action: async () => {
|
||||
const response = await regenerateAdminTaskClaimLink(item.taskId);
|
||||
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl;
|
||||
const response = await rebindAdminTaskKuaishouCloudRole(item.taskId)
|
||||
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl
|
||||
},
|
||||
successMessage: "领取链接已重新生成",
|
||||
confirmText: `确认重新生成任务 ${item.taskNo} 的领取链接吗?旧链接会失效。`,
|
||||
successMessage: '角色换绑资源已准备完成',
|
||||
confirmText: `确认为任务 ${item.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||
onAfterAction: async () => {
|
||||
await loadTasks();
|
||||
await loadTasks()
|
||||
},
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
if (actionKey === "manual_review") {
|
||||
if (actionKey === 'manual_review') {
|
||||
void runAction({
|
||||
id: item.taskId,
|
||||
action: () => markAdminTaskManualReview(item.taskId),
|
||||
successMessage: "任务已转人工处理",
|
||||
successMessage: '任务已转人工处理',
|
||||
confirmText: `确认将任务 ${item.taskNo} 转为人工处理吗?`,
|
||||
onAfterAction: async () => {
|
||||
await loadTasks();
|
||||
await loadTasks()
|
||||
},
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
void runAction({
|
||||
id: item.taskId,
|
||||
action: () => closeAdminTask(item.taskId),
|
||||
successMessage: "任务已关闭",
|
||||
successMessage: '任务已关闭',
|
||||
confirmText: `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||
onAfterAction: async () => {
|
||||
await loadTasks();
|
||||
await loadTasks()
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
status.value = "";
|
||||
taskNo.value = "";
|
||||
platformOrderId.value = "";
|
||||
roleId.value = "";
|
||||
skuCode.value = "";
|
||||
dateFrom.value = "";
|
||||
dateTo.value = "";
|
||||
void loadTasks(1);
|
||||
status.value = ''
|
||||
taskNo.value = ''
|
||||
platformOrderId.value = ''
|
||||
roleId.value = ''
|
||||
skuCode.value = ''
|
||||
dateFrom.value = ''
|
||||
dateTo.value = ''
|
||||
void loadTasks(1)
|
||||
}
|
||||
|
||||
function resolveResourceStageLabel(status: string) {
|
||||
const normalized = String(status || "").trim();
|
||||
const normalized = String(status || '').trim()
|
||||
const labelMap: Record<string, string> = {
|
||||
resource_ready: "资源已准备",
|
||||
pending_prepare: "待准备资源",
|
||||
pending_payment: "待支付",
|
||||
pending_binding_prepare: "待准备资源",
|
||||
retry_pending: "待重试",
|
||||
manual_review: "人工处理",
|
||||
resource_exception: "资源异常",
|
||||
closed: "已关闭",
|
||||
expired: "已过期",
|
||||
completed: "已完成",
|
||||
not_started: "未开始",
|
||||
};
|
||||
resource_ready: '资源已准备',
|
||||
pending_prepare: '待准备资源',
|
||||
pending_payment: '待支付',
|
||||
pending_binding_prepare: '待准备资源',
|
||||
retry_pending: '待重试',
|
||||
manual_review: '人工处理',
|
||||
resource_exception: '资源异常',
|
||||
closed: '已关闭',
|
||||
expired: '已过期',
|
||||
completed: '已完成',
|
||||
not_started: '未开始',
|
||||
}
|
||||
|
||||
return labelMap[normalized] || normalized || "-";
|
||||
return labelMap[normalized] || normalized || '-'
|
||||
}
|
||||
|
||||
function resolveCustomerStageLabel(status: string) {
|
||||
const normalized = String(status || "").trim();
|
||||
const normalized = String(status || '').trim()
|
||||
const labelMap: Record<string, string> = {
|
||||
not_started: "待打开链接",
|
||||
waiting_customer: "待客户绑定",
|
||||
link_opened: "已打开链接",
|
||||
customer_processing: "处理中",
|
||||
customer_confirmed: "已提交",
|
||||
customer_completed: "客户已完成",
|
||||
customer_exception: "客户步骤异常",
|
||||
closed: "已关闭",
|
||||
expired: "已过期",
|
||||
};
|
||||
not_started: '待打开链接',
|
||||
waiting_customer: '待客户绑定',
|
||||
link_opened: '已打开链接',
|
||||
customer_processing: '处理中',
|
||||
customer_confirmed: '已提交',
|
||||
customer_completed: '客户已完成',
|
||||
customer_exception: '客户步骤异常',
|
||||
closed: '已关闭',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
return labelMap[normalized] || normalized || "-";
|
||||
return labelMap[normalized] || normalized || '-'
|
||||
}
|
||||
|
||||
function resolveTaskMainText(item: AdminTaskListItem) {
|
||||
if (item.lastError) return "需要关注";
|
||||
if (["failed", "manual_review", "retry_pending"].includes(item.status)) return "待处理";
|
||||
if (["redeemed", "completed", "closed"].includes(item.status)) return "已完成";
|
||||
if (item.customerStatus === "waiting_customer") return "等待客户绑定";
|
||||
if (item.resourceStatus === "resource_ready") return "资源已准备";
|
||||
if (item.status === "pending_binding_prepare") return "待准备资源";
|
||||
return item.status || "处理中";
|
||||
if (item.lastError) return '需要关注'
|
||||
if (['failed', 'manual_review', 'retry_pending'].includes(item.status)) return '待处理'
|
||||
if (['redeemed', 'completed', 'closed'].includes(item.status)) return '已完成'
|
||||
if (item.customerStatus === 'waiting_customer') return '等待客户绑定'
|
||||
if (item.resourceStatus === 'resource_ready') return '资源已准备'
|
||||
if (item.status === 'pending_binding_prepare') return '待准备资源'
|
||||
return item.status || '处理中'
|
||||
}
|
||||
|
||||
function resolveTaskTone(item: AdminTaskListItem) {
|
||||
if (item.lastError || ["failed", "manual_review", "retry_pending"].includes(item.status)) return "danger";
|
||||
if (["redeemed", "completed", "closed"].includes(item.status)) return "success";
|
||||
return "warning";
|
||||
if (item.lastError || ['failed', 'manual_review', 'retry_pending'].includes(item.status))
|
||||
return 'danger'
|
||||
if (['redeemed', 'completed', 'closed'].includes(item.status)) return 'success'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function canRebindListTask(item: AdminTaskListItem) {
|
||||
return (
|
||||
canManageTaskLifecycle &&
|
||||
item.executorKey === 'kuaishou_ct_assisted' &&
|
||||
['waiting_binding', 'role_confirmed', 'manual_review', 'retry_pending'].includes(item.status)
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadTasks(1);
|
||||
});
|
||||
void loadTasks(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tasks-page list-page">
|
||||
<AdminPageHeader
|
||||
title="交付任务"
|
||||
description="查看自动履约任务、客户步骤和异常处理。"
|
||||
>
|
||||
<AdminPageHeader title="交付任务" description="查看自动履约任务、客户步骤和异常处理。">
|
||||
<template #extra>
|
||||
<span class="total-badge">共 {{ pagination.total }} 条任务</span>
|
||||
</template>
|
||||
@@ -198,9 +197,7 @@ onMounted(() => {
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">查询任务</span>
|
||||
<span class="card-desc"
|
||||
>按状态、任务号、订单号、商品和日期筛选。</span
|
||||
>
|
||||
<span class="card-desc">按状态、任务号、订单号、商品和日期筛选。</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-grid">
|
||||
@@ -224,12 +221,7 @@ onMounted(() => {
|
||||
clearable
|
||||
@keyup.enter="loadTasks(1)"
|
||||
/>
|
||||
<el-input
|
||||
v-model="roleId"
|
||||
placeholder="角色 ID"
|
||||
clearable
|
||||
@keyup.enter="loadTasks(1)"
|
||||
/>
|
||||
<el-input v-model="roleId" placeholder="角色 ID" clearable @keyup.enter="loadTasks(1)" />
|
||||
<el-input
|
||||
v-model="skuCode"
|
||||
placeholder="商品名 / SKU"
|
||||
@@ -265,7 +257,7 @@ onMounted(() => {
|
||||
/>
|
||||
<el-alert
|
||||
v-if="lastClaimUrl"
|
||||
:title="`最近生成的领取链接:${lastClaimUrl}`"
|
||||
:title="`最近可用领取链接:${lastClaimUrl}`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
@@ -285,17 +277,10 @@ onMounted(() => {
|
||||
<template v-else>
|
||||
<div class="tasks-toolbar table-toolbar">
|
||||
<strong>任务列表</strong>
|
||||
<span
|
||||
>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span
|
||||
>
|
||||
<span>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="items"
|
||||
stripe
|
||||
size="small"
|
||||
class="tasks-table data-table"
|
||||
>
|
||||
<el-table :data="items" stripe size="small" class="tasks-table data-table">
|
||||
<el-table-column label="任务 / 订单" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
@@ -310,9 +295,7 @@ onMounted(() => {
|
||||
<span class="compact-chip" :title="row.platformOrderId"
|
||||
>订单 {{ row.platformOrderId }}</span
|
||||
>
|
||||
<span class="compact-chip compact-chip--muted"
|
||||
>重试 {{ row.retryCount }} 次</span
|
||||
>
|
||||
<span class="compact-chip compact-chip--muted">重试 {{ row.retryCount }} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -323,7 +306,7 @@ onMounted(() => {
|
||||
<span class="cell-title" :title="row.skuName || row.skuCode">{{
|
||||
row.skuName || row.skuCode
|
||||
}}</span>
|
||||
<span class="cell-subline">SKU {{ row.skuCode || "-" }}</span>
|
||||
<span class="cell-subline">SKU {{ row.skuCode || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -334,11 +317,11 @@ onMounted(() => {
|
||||
{{ resolveTaskMainText(row) }}
|
||||
</span>
|
||||
<p class="task-status-note">
|
||||
客户 {{ resolveCustomerStageLabel(row.customerStatus) }}
|
||||
· 资源 {{ resolveResourceStageLabel(row.resourceStatus) }}
|
||||
客户 {{ resolveCustomerStageLabel(row.customerStatus) }} · 资源
|
||||
{{ resolveResourceStageLabel(row.resourceStatus) }}
|
||||
</p>
|
||||
<p v-if="row.roleName || row.roleId" class="task-status-note">
|
||||
角色 {{ row.roleName || "-" }} / {{ row.roleId || "-" }}
|
||||
角色 {{ row.roleName || '-' }} / {{ row.roleId || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -346,12 +329,8 @@ onMounted(() => {
|
||||
<el-table-column label="时间" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-subline"
|
||||
>创建 {{ formatAdminDateTime(row.createdAt) }}</span
|
||||
>
|
||||
<span class="cell-subline"
|
||||
>更新 {{ formatAdminDateTime(row.updatedAt) }}</span
|
||||
>
|
||||
<span class="cell-subline">创建 {{ formatAdminDateTime(row.createdAt) }}</span>
|
||||
<span class="cell-subline">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
|
||||
<span v-if="row.claimedAt" class="cell-subline"
|
||||
>领取 {{ formatAdminDateTime(row.claimedAt) }}</span
|
||||
>
|
||||
@@ -368,7 +347,7 @@ onMounted(() => {
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-error" :title="row.lastError || '-'">{{
|
||||
row.lastError || "当前无异常"
|
||||
row.lastError || '当前无异常'
|
||||
}}</span>
|
||||
<div class="action-stack">
|
||||
<el-button
|
||||
@@ -386,14 +365,11 @@ onMounted(() => {
|
||||
重试
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="
|
||||
canManageTaskLifecycle &&
|
||||
row.executorKey !== 'manual_dispatch'
|
||||
"
|
||||
v-if="canRebindListTask(row)"
|
||||
size="small"
|
||||
@click="handleActionCommand('regenerate_claim_link', row)"
|
||||
@click="handleActionCommand('rebind_role', row)"
|
||||
>
|
||||
重发链接
|
||||
换绑角色
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
@@ -403,10 +379,7 @@ onMounted(() => {
|
||||
转人工
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="
|
||||
canCloseTasks &&
|
||||
!['redeemed', 'closed'].includes(row.status)
|
||||
"
|
||||
v-if="canCloseTasks && !['redeemed', 'closed'].includes(row.status)"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleActionCommand('close', row)"
|
||||
@@ -434,7 +407,7 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../../../styles/admin-list-pages.css";
|
||||
@import '../../../styles/admin-list-pages.css';
|
||||
|
||||
.filter-grid {
|
||||
grid-template-columns:
|
||||
@@ -465,19 +438,19 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-main-chip[data-tone="success"] {
|
||||
.task-main-chip[data-tone='success'] {
|
||||
color: #0f766e;
|
||||
background: var(--color-success-light);
|
||||
border-color: #a6f4c5;
|
||||
}
|
||||
|
||||
.task-main-chip[data-tone="warning"] {
|
||||
.task-main-chip[data-tone='warning'] {
|
||||
color: #b54708;
|
||||
background: var(--color-warning-light);
|
||||
border-color: #fedf89;
|
||||
}
|
||||
|
||||
.task-main-chip[data-tone="danger"] {
|
||||
.task-main-chip[data-tone='danger'] {
|
||||
color: var(--color-danger-dark);
|
||||
background: var(--color-danger-light);
|
||||
border-color: #fecaca;
|
||||
|
||||
@@ -12,7 +12,7 @@ defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
regenerateClaimLink: []
|
||||
rebindRole: []
|
||||
prepareKuaishouCloud: []
|
||||
dispatchKuaishouCloud: []
|
||||
returnKuaishouCloud: []
|
||||
@@ -35,13 +35,13 @@ const emit = defineEmits<{
|
||||
重试任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canRegenerateClaimLink"
|
||||
:disabled="!detail.operations.canRegenerateClaimLink"
|
||||
v-if="detail.operations.canRebindKuaishouCloudRole"
|
||||
:disabled="!detail.operations.canRebindKuaishouCloudRole"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('regenerateClaimLink')"
|
||||
@click="emit('rebindRole')"
|
||||
>
|
||||
重发链接
|
||||
换绑角色
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canPrepareKuaishouCloudFulfillment"
|
||||
|
||||
@@ -69,11 +69,12 @@ function resolveTimelineItemClass(status: string, isCurrent: boolean) {
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
/** 计算 checklist 中第一个「非完成」的步骤索引,即当前卡点 */
|
||||
function findCurrentStepIndex(checklist: ChecklistItem[]) {
|
||||
const doneStatuses = ['verified', 'ready', 'completed', 'done']
|
||||
const idx = checklist.findIndex((item) => !doneStatuses.includes(String(item.status || '').trim()))
|
||||
const idx = checklist.findIndex(
|
||||
(item) => !doneStatuses.includes(String(item.status || '').trim()),
|
||||
)
|
||||
return idx
|
||||
}
|
||||
|
||||
@@ -108,8 +109,29 @@ function badgeLabel(tone: string) {
|
||||
return '待处理'
|
||||
}
|
||||
|
||||
const roleHasIdentity = (flow: KuaishouCloudFlow) =>
|
||||
!!(flow.role?.name || flow.role?.rid)
|
||||
const roleHasIdentity = (flow: KuaishouCloudFlow) => !!(flow.role?.name || flow.role?.rid)
|
||||
|
||||
function formatRebindSource(source: string) {
|
||||
const normalized = String(source || '').trim()
|
||||
if (normalized.includes('claim_page')) return '客户自助'
|
||||
if (normalized.includes('admin')) return '后台操作'
|
||||
return normalized || '-'
|
||||
}
|
||||
|
||||
function formatRebindBinding(
|
||||
binding: { vnId?: number; vnPhone?: string; roleName?: string; roleId?: string } | null,
|
||||
) {
|
||||
if (!binding) return '-'
|
||||
|
||||
const vn = binding.vnId ? `虚拟号 ${binding.vnId}` : '虚拟号 -'
|
||||
const phone = binding.vnPhone ? ` / ${binding.vnPhone}` : ''
|
||||
const role =
|
||||
binding.roleName || binding.roleId
|
||||
? `;角色 ${binding.roleName || '-'} / ${binding.roleId || '-'}`
|
||||
: ''
|
||||
|
||||
return `${vn}${phone}${role}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -155,9 +177,7 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
|
||||
<div class="spotlight-head">
|
||||
<div>
|
||||
<h4>客户角色</h4>
|
||||
<p class="flow-copy">
|
||||
客户绑定后刷新角色信息,确认无误后再执行发货。
|
||||
</p>
|
||||
<p class="flow-copy">客户绑定后刷新角色信息,确认无误后再执行发货。</p>
|
||||
</div>
|
||||
<div class="spotlight-actions">
|
||||
<AdminStatusTag :status="flow.role?.status || 'pending'" />
|
||||
@@ -187,9 +207,7 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 已识别徽章 -->
|
||||
<div v-if="roleHasIdentity(flow)" class="role-verified-badge">
|
||||
✓ 角色已识别
|
||||
</div>
|
||||
<div v-if="roleHasIdentity(flow)" class="role-verified-badge">✓ 角色已识别</div>
|
||||
<!-- 等待识别引导 -->
|
||||
<div v-else class="role-pending-hint">
|
||||
客户完成绑定后,点击右上角「刷新」按钮获取角色信息,确认角色名与 ID 无误后再执行发货。
|
||||
@@ -202,9 +220,7 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
|
||||
<!-- 流程进度 Timeline -->
|
||||
<section class="flow-block flow-block--checklist">
|
||||
<h4>流程进度</h4>
|
||||
<p class="flow-copy">
|
||||
当前任务只需要按这个顺序推进,异常时再展开下方明细。
|
||||
</p>
|
||||
<p class="flow-copy">当前任务只需要按这个顺序推进,异常时再展开下方明细。</p>
|
||||
<div class="flow-timeline">
|
||||
<div
|
||||
v-for="(item, idx) in checklist"
|
||||
@@ -218,10 +234,9 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-head">
|
||||
<span class="timeline-label">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="idx === findCurrentStepIndex(checklist)"
|
||||
class="timeline-current-badge"
|
||||
>当前</span>
|
||||
<span v-if="idx === findCurrentStepIndex(checklist)" class="timeline-current-badge"
|
||||
>当前</span
|
||||
>
|
||||
</div>
|
||||
<div class="timeline-detail">{{ item.detail }}</div>
|
||||
</div>
|
||||
@@ -404,6 +419,34 @@ const roleHasIdentity = (flow: KuaishouCloudFlow) =>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item v-if="flow.rebind?.history?.length" name="rebind" title="换绑记录">
|
||||
<div class="event-timeline compact-event-timeline">
|
||||
<div
|
||||
v-for="item in flow.rebind.history"
|
||||
:key="`rebind-${item.attempt}-${item.requestedAt}`"
|
||||
class="event-tl-item"
|
||||
>
|
||||
<div class="event-tl-gutter">
|
||||
<div class="event-tl-dot"></div>
|
||||
</div>
|
||||
<div class="event-tl-body">
|
||||
<div class="event-tl-type">
|
||||
第 {{ item.attempt }} 次换绑 · {{ formatRebindSource(item.source) }} ·
|
||||
{{ item.status === 'success' ? '成功' : '失败' }}
|
||||
</div>
|
||||
<div class="event-tl-payload">原 {{ formatRebindBinding(item.oldBinding) }}</div>
|
||||
<div v-if="item.newBinding" class="event-tl-payload">
|
||||
新 {{ formatRebindBinding(item.newBinding) }}
|
||||
</div>
|
||||
<div v-if="item.errorMessage" class="event-tl-payload">
|
||||
{{ item.errorMessage }}
|
||||
</div>
|
||||
<div class="event-tl-time">{{ formatAdminDateTime(item.requestedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
@@ -69,7 +69,9 @@ const claim = useKuaishouCloudClaim(() => props.token)
|
||||
:role-id="claim.roleId.value"
|
||||
:product="claim.product.value"
|
||||
:redeeming="claim.redeeming.value"
|
||||
:rebinding-role="claim.rebindingRole.value"
|
||||
@confirm-redeem="claim.confirmRedeem()"
|
||||
@rebind-role="claim.rebindRole()"
|
||||
/>
|
||||
|
||||
<ClaimResultStep
|
||||
|
||||
@@ -10,10 +10,12 @@ defineProps<{
|
||||
roleId: string
|
||||
product: ClaimProductInfo | null
|
||||
redeeming: boolean
|
||||
rebindingRole: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
confirmRedeem: []
|
||||
rebindRole: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -48,9 +50,26 @@ defineEmits<{
|
||||
<span>兑换后不可取消,也不可退货。</span>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" size="large" :loading="redeeming" @click="$emit('confirmRedeem')">
|
||||
确认兑换
|
||||
</el-button>
|
||||
<div class="action-row">
|
||||
<el-button
|
||||
size="large"
|
||||
plain
|
||||
:loading="rebindingRole"
|
||||
:disabled="redeeming"
|
||||
@click="$emit('rebindRole')"
|
||||
>
|
||||
换绑角色
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="redeeming"
|
||||
:disabled="rebindingRole"
|
||||
@click="$emit('confirmRedeem')"
|
||||
>
|
||||
确认兑换
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -120,6 +139,17 @@ defineEmits<{
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-row :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.content-card {
|
||||
max-width: 100%;
|
||||
@@ -128,5 +158,14 @@ defineEmits<{
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.action-row :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+196
-184
@@ -1,380 +1,390 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
import { showError, showSuccess } from "@/lib/feedback";
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
hasKuaishouCloudRedeemResultStatus,
|
||||
isClaimInactiveTaskStatus,
|
||||
isKuaishouCloudCompletedStatus,
|
||||
isKuaishouCloudRoleConfirmedStatus,
|
||||
} from "@/domain/task-status";
|
||||
import { formatAdminDateTime } from "@/utils/admin-time";
|
||||
} from '@/domain/task-status'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import {
|
||||
confirmKuaishouCloudClaimRole,
|
||||
fetchClaimDetail,
|
||||
rebindKuaishouCloudClaimRole,
|
||||
redeemKuaishouCloudClaim,
|
||||
verifyKuaishouCloudClaimTicket,
|
||||
} from "@/services/claim";
|
||||
import type { ClaimDetailData } from "@/types/claim";
|
||||
} from '@/services/claim'
|
||||
import type { ClaimDetailData } from '@/types/claim'
|
||||
|
||||
const BINDING_PREPARE_POLL_MS = 5_000;
|
||||
const ROLE_FAST_POLL_MS = 10_000;
|
||||
const ROLE_SLOW_POLL_MS = 30_000;
|
||||
const ROLE_IDLE_POLL_MS = 60_000;
|
||||
const ROLE_FAST_WINDOW_MS = 3 * 60_000;
|
||||
const ROLE_IDLE_WINDOW_MS = 10 * 60_000;
|
||||
const BINDING_PREPARE_POLL_MS = 5_000
|
||||
const ROLE_FAST_POLL_MS = 10_000
|
||||
const ROLE_SLOW_POLL_MS = 30_000
|
||||
const ROLE_IDLE_POLL_MS = 60_000
|
||||
const ROLE_FAST_WINDOW_MS = 3 * 60_000
|
||||
const ROLE_IDLE_WINDOW_MS = 10 * 60_000
|
||||
|
||||
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;
|
||||
let rolePollBaselineAt = 0;
|
||||
let rolePollBaselineKey = "";
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const refreshingRole = ref(false)
|
||||
const confirmingRole = ref(false)
|
||||
const rebindingRole = ref(false)
|
||||
const redeeming = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<ClaimDetailData | null>(null)
|
||||
const ticketCode = ref('')
|
||||
const qrCodeDataUrl = ref('')
|
||||
let pollTimer = 0
|
||||
let rolePollBaselineAt = 0
|
||||
let rolePollBaselineKey = ''
|
||||
|
||||
// ── derived data ────────────────────────────────────────
|
||||
|
||||
const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null);
|
||||
const order = computed(() => detail.value?.order || null);
|
||||
const orderItem = computed(() => detail.value?.orderItem || null);
|
||||
const product = computed(() => detail.value?.product || null);
|
||||
const task = computed(() => detail.value?.task || null);
|
||||
const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
|
||||
const order = computed(() => detail.value?.order || null)
|
||||
const orderItem = computed(() => detail.value?.orderItem || null)
|
||||
const product = computed(() => detail.value?.product || 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 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();
|
||||
const expiresAt = String(flow.value?.binding.bindExpiresAt || '').trim()
|
||||
if (!expiresAt) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
const expiresTime = Date.parse(expiresAt);
|
||||
return Number.isFinite(expiresTime) && expiresTime <= Date.now();
|
||||
});
|
||||
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
|
||||
);
|
||||
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(
|
||||
() => isKuaishouCloudRoleConfirmedStatus(task.value?.status)
|
||||
);
|
||||
const isBindingPreparing = computed(() => flow.value?.binding.prepareStatus === 'pending')
|
||||
const canEnterBindingStep = computed(() => isTicketVerified.value)
|
||||
const isRoleReady = computed(() => Boolean(roleName.value || roleId.value))
|
||||
const isRoleConfirmed = computed(() => isKuaishouCloudRoleConfirmedStatus(task.value?.status))
|
||||
const isDispatched = computed(
|
||||
() => String(flow.value?.dispatch.status || "").trim() === "success"
|
||||
);
|
||||
() => String(flow.value?.dispatch.status || '').trim() === 'success',
|
||||
)
|
||||
|
||||
const isCompleted = computed(
|
||||
() =>
|
||||
isKuaishouCloudCompletedStatus(task.value?.status) ||
|
||||
String(flow.value?.consume.status || "").trim() === "success"
|
||||
);
|
||||
String(flow.value?.consume.status || '').trim() === 'success',
|
||||
)
|
||||
|
||||
const hasRedeemResult = computed(() => {
|
||||
return isDispatched.value || hasKuaishouCloudRedeemResultStatus(task.value?.status);
|
||||
});
|
||||
return isDispatched.value || hasKuaishouCloudRedeemResultStatus(task.value?.status)
|
||||
})
|
||||
|
||||
const canSubmitTicket = computed(
|
||||
() =>
|
||||
!isClaimInactiveTaskStatus(task.value?.status) && !submitting.value
|
||||
);
|
||||
() => !isClaimInactiveTaskStatus(task.value?.status) && !submitting.value,
|
||||
)
|
||||
|
||||
const currentStep = computed(() => {
|
||||
if (hasRedeemResult.value) {
|
||||
return 4;
|
||||
return 4
|
||||
}
|
||||
if (isRoleConfirmed.value) {
|
||||
return 3;
|
||||
return 3
|
||||
}
|
||||
if (canEnterBindingStep.value) {
|
||||
return 2;
|
||||
return 2
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
return 1
|
||||
})
|
||||
|
||||
const progressText = computed(() => {
|
||||
if (hasRedeemResult.value) {
|
||||
return "兑换结果已生成";
|
||||
return '兑换结果已生成'
|
||||
}
|
||||
if (isRoleConfirmed.value) {
|
||||
return "角色已确认,等待兑换";
|
||||
return '角色已确认,等待兑换'
|
||||
}
|
||||
if (isTicketVerified.value) {
|
||||
return isBindingPrepared.value
|
||||
? "请完成扫码绑定"
|
||||
: "绑定链接刷新中,请稍候";
|
||||
return isBindingPrepared.value ? '请完成扫码绑定' : '绑定链接刷新中,请稍候'
|
||||
}
|
||||
return "等待提交核销码";
|
||||
});
|
||||
return '等待提交核销码'
|
||||
})
|
||||
|
||||
const resultTitle = computed(() => {
|
||||
if (isCompleted.value) {
|
||||
return "兑换成功";
|
||||
return '兑换成功'
|
||||
}
|
||||
if (isDispatched.value) {
|
||||
return "兑换请求已提交";
|
||||
return '兑换请求已提交'
|
||||
}
|
||||
return "结果已记录";
|
||||
});
|
||||
return '结果已记录'
|
||||
})
|
||||
|
||||
const resultDescription = computed(() => {
|
||||
if (isCompleted.value) {
|
||||
return "当前兑换流程已经完成。发货、退号和核销结果会保存在后台任务界面。";
|
||||
return '当前兑换流程已经完成。发货、退号和核销结果会保存在后台任务界面。'
|
||||
}
|
||||
return "你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。";
|
||||
});
|
||||
return '你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。'
|
||||
})
|
||||
|
||||
// ── actions ─────────────────────────────────────────────
|
||||
|
||||
async function generateQRCode(url: string) {
|
||||
if (!url) {
|
||||
qrCodeDataUrl.value = "";
|
||||
return;
|
||||
qrCodeDataUrl.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
qrCodeDataUrl.value = await QRCode.toDataURL(url, {
|
||||
width: 280,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: "#0f172a",
|
||||
light: "#ffffff",
|
||||
dark: '#0f172a',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
qrCodeDataUrl.value = "";
|
||||
console.error("生成二维码失败:", error);
|
||||
qrCodeDataUrl.value = ''
|
||||
console.error('生成二维码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDetail(nextDetail: ClaimDetailData) {
|
||||
detail.value = nextDetail;
|
||||
detail.value = nextDetail
|
||||
if (!ticketCode.value && nextDetail.kuaishouCloudFulfillment?.ticket.code) {
|
||||
ticketCode.value = nextDetail.kuaishouCloudFulfillment.ticket.code;
|
||||
ticketCode.value = nextDetail.kuaishouCloudFulfillment.ticket.code
|
||||
}
|
||||
await generateQRCode(
|
||||
String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || "").trim()
|
||||
);
|
||||
await generateQRCode(String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || '').trim())
|
||||
}
|
||||
|
||||
async function loadDetail(options: { silent?: boolean } = {}) {
|
||||
if (!options.silent) {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
}
|
||||
errorMessage.value = "";
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response = await fetchClaimDetail(token());
|
||||
await applyDetail(response.data);
|
||||
syncPolling();
|
||||
const response = await fetchClaimDetail(token())
|
||||
await applyDetail(response.data)
|
||||
syncPolling()
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : "读取领取信息失败";
|
||||
stopPolling();
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取领取信息失败'
|
||||
stopPolling()
|
||||
} finally {
|
||||
if (!options.silent) {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTicket() {
|
||||
const normalizedTicketCode = ticketCode.value.trim();
|
||||
const normalizedTicketCode = ticketCode.value.trim()
|
||||
if (!normalizedTicketCode) {
|
||||
showError("请输入核销码");
|
||||
return;
|
||||
showError('请输入核销码')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
submitting.value = true
|
||||
try {
|
||||
const response = await verifyKuaishouCloudClaimTicket(token(), {
|
||||
ticketCode: normalizedTicketCode,
|
||||
});
|
||||
await applyDetail(response.data);
|
||||
showSuccess("核销码验证通过,系统已开始准备绑定资源");
|
||||
syncPolling();
|
||||
})
|
||||
await applyDetail(response.data)
|
||||
showSuccess('核销码验证通过,系统已开始准备绑定资源')
|
||||
syncPolling()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : "核销码验证失败");
|
||||
showError(error instanceof Error ? error.message : '核销码验证失败')
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRole() {
|
||||
refreshingRole.value = true;
|
||||
refreshingRole.value = true
|
||||
try {
|
||||
await loadDetail({ silent: true });
|
||||
await loadDetail({ silent: true })
|
||||
if (isRoleReady.value) {
|
||||
showSuccess("角色信息已刷新");
|
||||
showSuccess('角色信息已刷新')
|
||||
} else {
|
||||
showError(
|
||||
flow.value?.role.errorMessage ||
|
||||
"暂时还没有识别到角色信息,请完成绑定后稍等片刻再试"
|
||||
);
|
||||
flow.value?.role.errorMessage || '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
refreshingRole.value = false;
|
||||
refreshingRole.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRole() {
|
||||
confirmingRole.value = true;
|
||||
confirmingRole.value = true
|
||||
try {
|
||||
const response = await confirmKuaishouCloudClaimRole(token());
|
||||
await applyDetail(response.data);
|
||||
showSuccess("角色已确认,进入下一步");
|
||||
syncPolling();
|
||||
const response = await confirmKuaishouCloudClaimRole(token())
|
||||
await applyDetail(response.data)
|
||||
showSuccess('角色已确认,进入下一步')
|
||||
syncPolling()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : "确认角色失败");
|
||||
showError(error instanceof Error ? error.message : '确认角色失败')
|
||||
} finally {
|
||||
confirmingRole.value = false;
|
||||
confirmingRole.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRedeem() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"兑换后不可取消,也不可退货。请确认角色和商品信息完全正确。",
|
||||
"确认兑换",
|
||||
'兑换后不可取消,也不可退货。请确认角色和商品信息完全正确。',
|
||||
'确认兑换',
|
||||
{
|
||||
confirmButtonText: "确认兑换",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
confirmButtonText: '确认兑换',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
center: true,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
redeeming.value = true;
|
||||
redeeming.value = true
|
||||
try {
|
||||
const response = await redeemKuaishouCloudClaim(token());
|
||||
await applyDetail(response.data);
|
||||
showSuccess("兑换请求已提交");
|
||||
syncPolling();
|
||||
const response = await redeemKuaishouCloudClaim(token())
|
||||
await applyDetail(response.data)
|
||||
showSuccess('兑换请求已提交')
|
||||
syncPolling()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : "兑换失败");
|
||||
showError(error instanceof Error ? error.message : '兑换失败')
|
||||
} finally {
|
||||
redeeming.value = false;
|
||||
redeeming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rebindRole() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。核销码和领取商品不会改变,但需要重新扫码绑定角色。',
|
||||
'确认换绑角色',
|
||||
{
|
||||
confirmButtonText: '确认换绑',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
center: true,
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
rebindingRole.value = true
|
||||
try {
|
||||
const response = await rebindKuaishouCloudClaimRole(token())
|
||||
await applyDetail(response.data)
|
||||
showSuccess('新的绑定二维码已生成,请重新绑定角色')
|
||||
syncPolling()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '换绑角色失败')
|
||||
} finally {
|
||||
rebindingRole.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openBindUrl(useCurrentPage = false) {
|
||||
const bindUrl = String(flow.value?.binding.bindUrl || "").trim();
|
||||
const bindUrl = String(flow.value?.binding.bindUrl || '').trim()
|
||||
if (!bindUrl) {
|
||||
showError("绑定链接还没准备好,请稍后刷新");
|
||||
return;
|
||||
showError('绑定链接还没准备好,请稍后刷新')
|
||||
return
|
||||
}
|
||||
if (useCurrentPage) {
|
||||
window.location.assign(bindUrl);
|
||||
return;
|
||||
window.location.assign(bindUrl)
|
||||
return
|
||||
}
|
||||
window.open(bindUrl, "_blank", "noopener,noreferrer");
|
||||
window.open(bindUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// ── polling ─────────────────────────────────────────────
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
window.clearTimeout(pollTimer);
|
||||
pollTimer = 0;
|
||||
window.clearTimeout(pollTimer)
|
||||
pollTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
function syncPolling() {
|
||||
const delay = resolveNextPollDelay();
|
||||
const delay = resolveNextPollDelay()
|
||||
if (!delay) {
|
||||
stopPolling();
|
||||
return;
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
|
||||
stopPolling();
|
||||
stopPolling()
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollTimer = 0;
|
||||
void loadDetail({ silent: true });
|
||||
}, delay);
|
||||
pollTimer = 0
|
||||
void loadDetail({ silent: true })
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function resolveNextPollDelay() {
|
||||
if (
|
||||
!flow.value ||
|
||||
hasRedeemResult.value ||
|
||||
isClaimInactiveTaskStatus(task.value?.status)
|
||||
) {
|
||||
return 0;
|
||||
if (!flow.value || hasRedeemResult.value || isClaimInactiveTaskStatus(task.value?.status)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (currentStep.value === 2) {
|
||||
if (!isBindingPrepared.value) {
|
||||
return BINDING_PREPARE_POLL_MS;
|
||||
return BINDING_PREPARE_POLL_MS
|
||||
}
|
||||
|
||||
return resolveRolePollDelay();
|
||||
return resolveRolePollDelay()
|
||||
}
|
||||
|
||||
if (currentStep.value === 3) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
|
||||
function resolveRolePollDelay() {
|
||||
const nextRoleKey = [roleName.value, roleId.value].join("::");
|
||||
const now = Date.now();
|
||||
const nextRoleKey = [roleName.value, roleId.value].join('::')
|
||||
const now = Date.now()
|
||||
|
||||
if (nextRoleKey !== rolePollBaselineKey) {
|
||||
rolePollBaselineKey = nextRoleKey;
|
||||
rolePollBaselineAt = now;
|
||||
rolePollBaselineKey = nextRoleKey
|
||||
rolePollBaselineAt = now
|
||||
}
|
||||
|
||||
if (!rolePollBaselineAt) {
|
||||
rolePollBaselineAt = now;
|
||||
rolePollBaselineAt = now
|
||||
}
|
||||
|
||||
const elapsedMs = now - rolePollBaselineAt;
|
||||
const elapsedMs = now - rolePollBaselineAt
|
||||
if (elapsedMs < ROLE_FAST_WINDOW_MS) {
|
||||
return ROLE_FAST_POLL_MS;
|
||||
return ROLE_FAST_POLL_MS
|
||||
}
|
||||
|
||||
if (elapsedMs < ROLE_IDLE_WINDOW_MS) {
|
||||
return ROLE_SLOW_POLL_MS;
|
||||
return ROLE_SLOW_POLL_MS
|
||||
}
|
||||
|
||||
return ROLE_IDLE_POLL_MS;
|
||||
return ROLE_IDLE_POLL_MS
|
||||
}
|
||||
|
||||
// ── lifecycle ───────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
void loadDetail();
|
||||
});
|
||||
void loadDetail()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
});
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
// state
|
||||
@@ -382,6 +392,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
submitting,
|
||||
refreshingRole,
|
||||
confirmingRole,
|
||||
rebindingRole,
|
||||
redeeming,
|
||||
errorMessage,
|
||||
detail,
|
||||
@@ -416,9 +427,10 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
submitTicket,
|
||||
refreshRole,
|
||||
confirmRole,
|
||||
rebindRole,
|
||||
confirmRedeem,
|
||||
openBindUrl,
|
||||
// utility
|
||||
formatAdminDateTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user