import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { showConfirm, showSuccess } from '@/lib/feedback' import { closeAdminManualRedeemTask, confirmAdminManualRedeemRole, createAdminManualRedeemSession, createAdminManualRedeemTask, fetchAdminManualRedeemDetail, fetchAdminManualRedeemSessionSummary, fetchAdminManualRedeemSkuSuggestions, fetchAdminTaskScreenshot, refreshAdminManualRedeemSession, redeemAdminManualRedeem, removeAdminManualRedeemSession, } from '@/services/admin' import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin' import type { TencentLoginType } from '@/types/tencent/session' import { notifyTencentActionError } from './tencent/session-errors' const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq' const POLL_INTERVAL_MS = 2500 const POLL_FAILURE_LIMIT = 3 const ACTIVE_TASK_STATUSES = new Set(['claimed', 'role_confirmed', 'redeeming']) export function useAdminManualRedeemPage() { const route = useRoute() const router = useRouter() const form = reactive({ proofValue: '', skuCode: '', remark: '', }) const suggestions = ref([]) const detail = ref(null) const detailLoading = ref(false) const createLoading = ref(false) const sessionLoading = ref(false) const roleConfirmLoading = ref(false) const redeemLoading = ref(false) const closeLoading = ref(false) const pollWarningMessage = ref('') const qrImageNaturalWidth = ref(0) const screenshotBlob = ref(null) const screenshotUrl = ref('') const screenshotLoading = ref(false) const loginType = ref(DEFAULT_LOGIN_TYPE) let pollTimer: ReturnType | null = null let pollToken = 0 let pollFailureCount = 0 const session = computed(() => detail.value?.session || null) const task = computed(() => detail.value?.task || null) const order = computed(() => detail.value?.order || null) const orderItem = computed(() => detail.value?.orderItem || null) const result = computed(() => detail.value?.result || null) const manualRequest = computed(() => detail.value?.manualRequest || null) const activityInfo = computed(() => session.value?.activityInfo || null) const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) const roleConfirmed = computed(() => Boolean(task.value && ['role_confirmed', 'redeeming', 'redeemed'].includes(task.value.status)), ) const hasSession = computed(() => Boolean(session.value?.sessionId)) const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) const sessionNotice = computed( () => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '', ) const qrImage = computed(() => session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '', ) const currentTaskId = computed(() => Number(task.value?.taskId || route.query.taskId || 0) || 0) const reviewReady = computed( () => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed', ) const resultReady = computed( () => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed', ) const loginTabs = [ { value: 'qq' as const, label: 'QQ账号登录' }, { value: 'wx' as const, label: '微信账号登录' }, ] const statusLabel = computed(() => resolveTaskStatusLabel(task.value?.status, session.value?.status), ) const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`) const scanInstruction = computed( () => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`, ) const roleFacts = computed(() => [ { label: '登录昵称', value: activityInfo.value?.nickname || '等待客户登录', }, { label: '当前角色', value: activityInfo.value?.role?.roleName || '后端浏览器同步中', accent: true, }, { label: '角色 ID', value: activityInfo.value?.role?.roleId || '未识别', }, { label: '所在大区', value: activityInfo.value?.role?.area || '未识别', }, ]) const resultFacts = computed(() => [ { label: '唯一凭据', value: manualRequest.value?.proofValue || order.value?.platformOrderId || '-', }, { label: '商品', value: orderItem.value?.skuName || '-', }, { label: '业务返回码', value: result.value?.resultCode || '-', }, { label: '业务消息', value: result.value?.resultMessage || '尚未兑换', }, ]) const canConfirmRole = computed(() => Boolean( task.value && task.value.status === 'claimed' && hasSession.value && roleReady.value && !roleConfirmLoading.value, ), ) const canRedeem = computed(() => Boolean( task.value && ['role_confirmed', 'redeeming'].includes(task.value.status) && !redeemLoading.value, ), ) const redeemBlockedReason = computed(() => { if (!task.value) { return '请先创建人工兑换任务并预占库存' } if (!hasSession.value) { return `请先生成${loginTypeLabel.value}二维码并等待客户登录` } if (redeemLoading.value) { return '兑换任务正在执行中' } if (!roleReady.value) { return sessionNotice.value || '客户登录后,系统会自动同步角色和大区信息' } if (!roleConfirmed.value) { return '请先让客户确认角色截图,再点击确认当前角色' } return '' }) const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换')) const screenshotEmptyTitle = computed(() => task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图', ) const screenshotEmptyMessage = computed(() => task.value?.status === 'redeemed' ? '本次兑换可能未生成最终截图,或截图仍在同步中。' : '客户登录并识别到角色后,这里会展示给客服复核的角色截图。', ) const canCreateTask = computed( () => Boolean(form.proofValue.trim() && form.skuCode.trim()) && !createLoading.value, ) const hasActiveTask = computed(() => Boolean(task.value?.taskId)) watch(qrImage, () => { qrImageNaturalWidth.value = 0 }) watch( () => currentTaskId.value, (taskId, previousTaskId) => { if (!taskId || taskId === previousTaskId) { return } void loadDetail(taskId) }, { immediate: true }, ) onMounted(() => { void loadSkuSuggestions() }) onBeforeUnmount(() => { resetPolling() clearScreenshotPreview() }) async function loadSkuSuggestions(keyword = '') { try { const response = await fetchAdminManualRedeemSkuSuggestions({ credentialType: 'tencent_code', keyword: keyword.trim(), }) suggestions.value = response.data.items } catch (error) { notifyTencentActionError(error, '读取人工兑换 SKU 建议失败') } } async function loadDetail(taskId = currentTaskId.value) { if (!taskId) { detail.value = null clearScreenshotPreview() return false } detailLoading.value = true try { const response = await fetchAdminManualRedeemDetail(taskId) detail.value = response.data loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) await loadScreenshotPreview(response.data) restartPollingIfNeeded() return true } catch (error) { notifyTencentActionError(error, '读取人工兑换详情失败') return false } finally { detailLoading.value = false } } async function createTaskNow() { if (!canCreateTask.value) { return } createLoading.value = true try { const response = await createAdminManualRedeemTask({ proofValue: form.proofValue.trim(), skuCode: form.skuCode.trim(), skuName: resolveSkuName(form.skuCode), remark: form.remark.trim(), }) detail.value = response.data loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) await loadScreenshotPreview(response.data) await router.replace({ path: route.path, query: { taskId: String(response.data.task.taskId), }, }) showSuccess(response.msg || '人工兑换任务已创建') } catch (error) { notifyTencentActionError(error, '创建人工兑换任务失败') } finally { createLoading.value = false } } async function createSessionFlow(nextLoginType = loginType.value) { if (!task.value?.taskId) { return } sessionLoading.value = true resetPolling() resetPollingWarning() try { loginType.value = nextLoginType const response = await createAdminManualRedeemSession(task.value.taskId, { loginType: nextLoginType, forceRecreate: hasSession.value, }) detail.value = response.data loginType.value = syncLoginTypeFromDetail(response.data, nextLoginType) await loadScreenshotPreview(response.data) startPolling() } catch (error) { notifyTencentActionError(error, '创建人工兑换登录会话失败') } finally { sessionLoading.value = false } } async function refreshSessionFlow() { if (!task.value?.taskId) { return false } sessionLoading.value = true resetPolling() resetPollingWarning() try { const response = await refreshAdminManualRedeemSession(task.value.taskId) detail.value = response.data loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) await loadScreenshotPreview(response.data) restartPollingIfNeeded(response.data) return true } catch (error) { notifyTencentActionError(error, '刷新人工兑换会话失败') await refreshSessionSummary({ silent: true }) restartPollingIfNeeded() return false } finally { sessionLoading.value = false } } async function closeSessionFlow({ silent = false } = {}) { if (!task.value?.taskId) { return false } if (!silent) { sessionLoading.value = true } resetPolling() resetPollingWarning() try { const response = await removeAdminManualRedeemSession(task.value.taskId) detail.value = response.data loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) await loadScreenshotPreview(response.data) if (!silent) { showSuccess(response.msg || '人工兑换会话已关闭') } return true } catch (error) { if (!silent) { notifyTencentActionError(error, '关闭人工兑换会话失败') } return false } finally { if (!silent) { sessionLoading.value = false } } } async function refreshSessionSummary({ silent = false } = {}) { if (!task.value?.taskId) { return false } if (!silent) { sessionLoading.value = true resetPollingWarning() } try { const currentSession = detail.value?.session || null const response = await fetchAdminManualRedeemSessionSummary(task.value.taskId) detail.value = mergeDetail(detail.value, response.data) loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) if (shouldRefreshQrImage(currentSession, response.data.session)) { const fullResponse = await fetchAdminManualRedeemDetail(task.value.taskId) detail.value = mergeDetail(detail.value, fullResponse.data) loginType.value = syncLoginTypeFromDetail(fullResponse.data, loginType.value) } await loadScreenshotPreview(detail.value) if (silent) { resetPollingWarning() } return true } catch (error) { if (silent) { handleSilentPollingError(error) } else { notifyTencentActionError(error, '刷新人工兑换会话状态失败') } return false } finally { if (!silent) { sessionLoading.value = false } } } async function confirmRoleNow() { if (!task.value?.taskId || !canConfirmRole.value) { return } roleConfirmLoading.value = true try { const response = await confirmAdminManualRedeemRole(task.value.taskId) detail.value = response.data await loadScreenshotPreview(response.data) showSuccess(response.msg || '角色已确认') restartPollingIfNeeded(response.data) } catch (error) { notifyTencentActionError(error, '确认角色失败') await refreshSessionSummary({ silent: true }) } finally { roleConfirmLoading.value = false } } async function redeemNow() { if (!task.value?.taskId || !canRedeem.value) { return } redeemLoading.value = true resetPolling() resetPollingWarning() try { const response = await redeemAdminManualRedeem(task.value.taskId) detail.value = response.data await loadScreenshotPreview(response.data) showSuccess(response.msg || '兑换完成') restartPollingIfNeeded(response.data) } catch (error) { notifyTencentActionError(error, '人工兑换失败') await refreshSessionSummary({ silent: true }) restartPollingIfNeeded() } finally { redeemLoading.value = false } } async function closeTaskNow() { if (!task.value?.taskId) { return } try { await showConfirm( `确认关闭任务 ${task.value.taskNo} 吗?关闭后会释放未消耗的资源,并结束当前人工兑换流程。`, '关闭人工兑换', ) } catch { return } closeLoading.value = true try { const response = await closeAdminManualRedeemTask(task.value.taskId) detail.value = response.data await loadScreenshotPreview(response.data) resetPolling() showSuccess(response.msg || '人工兑换任务已关闭') } catch (error) { notifyTencentActionError(error, '关闭人工兑换任务失败') } finally { closeLoading.value = false } } async function switchLoginType(nextLoginType: TencentLoginType) { if (nextLoginType === loginType.value) { return } if (hasSession.value) { await createSessionFlow(nextLoginType) return } loginType.value = nextLoginType } function resetCurrentTask() { resetPolling() detail.value = null clearScreenshotPreview() void router.replace({ path: route.path, query: {}, }) } function handleQrImageLoad(event: Event) { const target = event.target if (!(target instanceof HTMLImageElement)) { return } qrImageNaturalWidth.value = target.naturalWidth || 0 } async function copyQrImage() { if (!qrImage.value) { return false } try { const response = await fetch(qrImage.value) const blob = await response.blob() await writeImageBlobToClipboard(blob, '复制二维码失败') showSuccess('二维码已复制到剪贴板') return true } catch (error) { notifyTencentActionError(error, '复制二维码失败') return false } } async function copyScreenshot() { if (!screenshotBlob.value) { return false } try { await writeImageBlobToClipboard(screenshotBlob.value, '复制截图失败') showSuccess('截图已复制到剪贴板') return true } catch (error) { notifyTencentActionError(error, '复制截图失败') return false } } function downloadScreenshot() { if (!screenshotBlob.value || !screenshotUrl.value) { return false } const anchor = document.createElement('a') anchor.href = screenshotUrl.value anchor.download = task.value?.status === 'redeemed' ? `manual-redeem-result-${task.value?.taskNo || 'task'}.png` : `manual-redeem-review-${task.value?.taskNo || 'task'}.png` anchor.click() return true } async function writeImageBlobToClipboard(blob: Blob, fallbackMessage: string) { if ( typeof navigator === 'undefined' || !navigator.clipboard || typeof window === 'undefined' || !('ClipboardItem' in window) ) { throw new Error('当前浏览器不支持直接复制图片,请改用截图发送或下载') } try { const ClipboardItemCtor = window.ClipboardItem as typeof ClipboardItem await navigator.clipboard.write([ new ClipboardItemCtor({ [blob.type || 'image/png']: blob, }), ]) } catch (error) { throw error instanceof Error ? error : new Error(fallbackMessage) } } async function loadScreenshotPreview(nextDetail = detail.value) { const taskId = Number(nextDetail?.task?.taskId || 0) const shouldFetch = Boolean( taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady), ) if (!shouldFetch) { clearScreenshotPreview() return } screenshotLoading.value = true try { const blob = await fetchAdminTaskScreenshot(taskId) clearScreenshotPreview() screenshotBlob.value = blob screenshotUrl.value = URL.createObjectURL(blob) } catch { clearScreenshotPreview() } finally { screenshotLoading.value = false } } function clearScreenshotPreview() { screenshotBlob.value = null if (screenshotUrl.value) { URL.revokeObjectURL(screenshotUrl.value) screenshotUrl.value = '' } } function resolveSkuName(skuCode: string) { const normalized = String(skuCode || '').trim() const matched = suggestions.value.find( (item) => String(item.skuCode || '').trim() === normalized, ) return matched?.skuCode || normalized } function startPolling() { const tokenId = ++pollToken const loop = async () => { if (tokenId !== pollToken || !task.value?.taskId) { return } await refreshSessionSummary({ silent: true }) if (tokenId !== pollToken || !shouldKeepPolling(detail.value)) { return } pollTimer = setTimeout(() => { void loop() }, POLL_INTERVAL_MS) } pollTimer = setTimeout(() => { void loop() }, POLL_INTERVAL_MS) } function restartPollingIfNeeded(nextDetail = detail.value) { resetPolling() if (shouldKeepPolling(nextDetail)) { startPolling() } } function resetPolling() { pollToken += 1 if (pollTimer) { clearTimeout(pollTimer) pollTimer = null } } function resetPollingWarning() { pollFailureCount = 0 pollWarningMessage.value = '' } function handleSilentPollingError(error: unknown) { console.error(error) pollFailureCount += 1 if (pollFailureCount < POLL_FAILURE_LIMIT) { return } pollWarningMessage.value = '人工兑换状态刷新失败,已暂停自动轮询,请手动刷新。' resetPolling() } const qrDisplayWidth = computed(() => { const naturalWidth = qrImageNaturalWidth.value if (!naturalWidth) { return 220 } if (naturalWidth < 160) { return Math.min(naturalWidth * 2, 220) } return Math.min(naturalWidth, 240) }) const qrFigureStyle = computed(() => ({ width: `${qrDisplayWidth.value}px`, maxWidth: '100%', })) const qrPreviewWidth = computed( () => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`, ) return { form, suggestions, detail, detailLoading, createLoading, sessionLoading, roleConfirmLoading, redeemLoading, closeLoading, loginType, loginTypeLabel, loginTabs, session, task, order, orderItem, manualRequest, activityInfo, roleReady, roleConfirmed, hasSession, qrImage, qrFigureStyle, qrPreviewWidth, statusLabel, sessionNotice, roleFacts, resultFacts, canConfirmRole, canRedeem, redeemBlockedReason, redeemButtonLabel, initButtonLabel, scanInstruction, screenshotEmptyTitle, screenshotEmptyMessage, screenshotUrl, screenshotLoading, reviewReady, resultReady, canCreateTask, hasActiveTask, createTaskNow, loadSkuSuggestions, createSessionFlow, refreshSessionFlow, closeSessionFlow, switchLoginType, confirmRoleNow, redeemNow, closeTaskNow, resetCurrentTask, handleQrImageLoad, copyQrImage, copyScreenshot, downloadScreenshot, } } function syncLoginTypeFromDetail( detail: AdminManualRedeemDetail, fallback: TencentLoginType = DEFAULT_LOGIN_TYPE, ) { const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim() if (nextLoginType === 'wx') { return 'wx' } if (nextLoginType === 'qq') { return 'qq' } return fallback } function shouldKeepPolling(detail: AdminManualRedeemDetail | null) { if (!detail?.task?.taskId || !detail.session?.sessionId) { return false } return ACTIVE_TASK_STATUSES.has(String(detail.task.status || '').trim()) } function resolveTaskStatusLabel(taskStatus?: string, sessionStatus?: string) { switch (taskStatus) { case 'link_generated': return '待生成二维码' case 'claimed': if (sessionStatus === 'scanned') { return '客户待确认' } if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') { return '已登录待复核' } return '等待客户登录' case 'role_confirmed': return '角色已确认' case 'redeeming': return '正在兑换' case 'redeemed': return '兑换完成' case 'waiting_inventory': return '等待库存' case 'retry_pending': return '等待重试' case 'closed': return '任务已关闭' default: return sessionStatus || taskStatus || '等待中' } } function mergeDetail(current: AdminManualRedeemDetail | null, next: AdminManualRedeemDetail) { if (next.session === null) { return { ...next, session: null, } } if (!current?.session) { return next } return { ...next, session: { ...current.session, ...next.session, qrImageBase64: typeof next.session.qrImageBase64 === 'string' ? next.session.qrImageBase64 : current.session.qrImageBase64, activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null, review: next.session.review ?? current.session.review ?? null, redeem: next.session.redeem ?? current.session.redeem ?? null, artifacts: next.session.artifacts || current.session.artifacts, }, } } function shouldRefreshQrImage( currentSession: AdminManualRedeemDetail['session'], nextSession: AdminManualRedeemDetail['session'], ) { if (!currentSession || !nextSession || !nextSession.artifacts?.hasQrImage) { return false } if (!currentSession.qrImageBase64) { return true } return Boolean(nextSession.qrUpdatedAt && nextSession.qrUpdatedAt !== currentSession.qrUpdatedAt) }