重构:前端架构优化 - 消灭巨石文件,统一代码规范
Phase 1 - 消灭巨石页面: - 拆分 AdminKuaishouCloudFulfillmentView (1,224行→kuaishou-cloud/子目录) - 拆分 AdminFulfillmentBindingsView (989行→bindings/子目录) - 拆分 AdminTaskDetailView (1,007行→9个子组件+2个composable) - 合并去重 useClaimPage + useAdminManualRedeemPage (1,554行→共享模块+差异化薄层) - 拆分 services/admin/platform-config.ts (477行→8个领域子模块) Phase 2 - 架构收口: - 拆分 types/admin.ts (925行→13个子文件+platform-config/子目录) - 统一 API code 检查(http.ts拦截器统一处理业务错误) - 修改 apiPost 签名消除 as unknown as 类型断言(6处) - 新增 BusinessError 类型便于错误分类处理 所有改动通过 vue-tsc --noEmit 零错误和 vite build 验证
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
export { useSessionPolling } from './useSessionPolling'
|
||||
export type { UseSessionPollingOptions } from './useSessionPolling'
|
||||
|
||||
export { useSessionQrDisplay } from './useSessionQrDisplay'
|
||||
|
||||
export {
|
||||
useSessionRoleFacts,
|
||||
useSessionResultFacts,
|
||||
buildRedeemBlockedReason,
|
||||
resolveTaskStatusLabel,
|
||||
syncLoginTypeFromDetail,
|
||||
} from './useSessionPresentation'
|
||||
export type { TaskStatusLabelContext, FactItem, UseSessionPresentationOptions, UseSessionResultFactsOptions } from './useSessionPresentation'
|
||||
|
||||
export { mergeSessionDetail, shouldRefreshQrImage, shouldKeepPolling } from './mergeSessionDetail'
|
||||
|
||||
export { DEFAULT_LOGIN_TYPE, POLL_INTERVAL_MS, POLL_FAILURE_LIMIT, ACTIVE_TASK_STATUSES } from './sessionConstants'
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ClaimDetailData } from '@/types/claim'
|
||||
import type {
|
||||
TencentBrowserSessionData,
|
||||
TencentBrowserSessionSummaryData,
|
||||
} from '@/types/tencent/session'
|
||||
|
||||
import type { ClaimTaskStatus } from '@/types/claim'
|
||||
import { ACTIVE_TASK_STATUSES } from './sessionConstants'
|
||||
|
||||
/**
|
||||
* Generic merge of session detail data, preserving QR image and other
|
||||
* fields that may be absent in summary responses.
|
||||
*
|
||||
* The `extraSessionFields` callback lets callers inject domain-specific
|
||||
* session merge logic (e.g. `review` for admin, `redeem` for claim).
|
||||
*/
|
||||
export function mergeSessionDetail<T extends ClaimDetailData>(
|
||||
current: T | null,
|
||||
next: T,
|
||||
extraSessionFields?: (
|
||||
currentSession: NonNullable<T['session']>,
|
||||
nextSession: NonNullable<T['session']>,
|
||||
) => Partial<NonNullable<T['session']>>,
|
||||
): T {
|
||||
if (next.session === null) {
|
||||
return {
|
||||
...next,
|
||||
session: null,
|
||||
} as T
|
||||
}
|
||||
|
||||
if (!current?.session) {
|
||||
return next
|
||||
}
|
||||
|
||||
const sessionBase = {
|
||||
...current.session,
|
||||
...next.session,
|
||||
qrImageBase64:
|
||||
typeof next.session.qrImageBase64 === 'string'
|
||||
? next.session.qrImageBase64
|
||||
: current.session.qrImageBase64,
|
||||
activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null,
|
||||
redeem: next.session.redeem ?? current.session.redeem ?? null,
|
||||
artifacts: next.session.artifacts || current.session.artifacts,
|
||||
} as NonNullable<T['session']>
|
||||
|
||||
const extras = extraSessionFields
|
||||
? extraSessionFields(current.session as NonNullable<T['session']>, next.session as NonNullable<T['session']>)
|
||||
: {}
|
||||
|
||||
return {
|
||||
...next,
|
||||
session: {
|
||||
...sessionBase,
|
||||
...extras,
|
||||
},
|
||||
} as T
|
||||
}
|
||||
|
||||
export function shouldRefreshQrImage(
|
||||
current: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null,
|
||||
next: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null,
|
||||
): boolean {
|
||||
if (!current || !next || !next.artifacts?.hasQrImage) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!current.qrImageBase64) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt)
|
||||
}
|
||||
|
||||
export function shouldKeepPolling<T extends ClaimDetailData>(detail: T | null): boolean {
|
||||
if (!detail?.session?.sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ACTIVE_TASK_STATUSES.has(detail.task.status as ClaimTaskStatus)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TencentLoginType } from '@/types/tencent/session'
|
||||
import type { ClaimTaskStatus } from '@/types/claim'
|
||||
|
||||
export const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
|
||||
export const POLL_INTERVAL_MS = 2_500
|
||||
export const POLL_FAILURE_LIMIT = 3
|
||||
export const ACTIVE_TASK_STATUSES = new Set<ClaimTaskStatus>(['claimed', 'role_confirmed', 'redeeming'])
|
||||
@@ -0,0 +1,93 @@
|
||||
import { computed, ref, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
import { POLL_INTERVAL_MS, POLL_FAILURE_LIMIT } from './sessionConstants'
|
||||
|
||||
export interface UseSessionPollingOptions<T> {
|
||||
/** Whether there is an active session – used as a guard inside the poll loop. */
|
||||
hasSession: ComputedRef<boolean>
|
||||
/** Called on each poll tick (typically refreshSessionSummary). */
|
||||
onPollTick: () => Promise<void>
|
||||
/** Optional: determines whether polling should continue. Defaults to checking hasSession. */
|
||||
shouldKeepPolling?: ComputedRef<boolean>
|
||||
/** Context-specific warning message prefix when polling fails repeatedly. */
|
||||
pollWarningPrefix: string
|
||||
}
|
||||
|
||||
export function useSessionPolling(options: UseSessionPollingOptions<unknown>) {
|
||||
const { hasSession, onPollTick, pollWarningPrefix } = options
|
||||
|
||||
const pollWarningMessage = ref('')
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pollToken = 0
|
||||
let pollFailureCount = 0
|
||||
|
||||
const sessionNotice = computed(() => pollWarningMessage.value)
|
||||
|
||||
function startPolling() {
|
||||
const tokenId = ++pollToken
|
||||
|
||||
const loop = async () => {
|
||||
if (tokenId !== pollToken || !hasSession.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await onPollTick()
|
||||
|
||||
if (tokenId !== pollToken) {
|
||||
return
|
||||
}
|
||||
|
||||
const keepPolling = options.shouldKeepPolling
|
||||
? options.shouldKeepPolling.value
|
||||
: hasSession.value
|
||||
|
||||
if (!keepPolling) {
|
||||
return
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
void loop()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
void loop()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
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 = `${pollWarningPrefix},已暂停自动轮询,请手动刷新。`
|
||||
resetPolling()
|
||||
}
|
||||
|
||||
return {
|
||||
pollWarningMessage,
|
||||
sessionNotice,
|
||||
startPolling,
|
||||
resetPolling,
|
||||
resetPollingWarning,
|
||||
handleSilentPollingError,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import type { ClaimDetailData } from '@/types/claim'
|
||||
import type { TencentLoginType } from '@/types/tencent/session'
|
||||
|
||||
import { DEFAULT_LOGIN_TYPE } from './sessionConstants'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// syncLoginTypeFromDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function syncLoginTypeFromDetail<T extends ClaimDetailData>(
|
||||
detail: T,
|
||||
fallback: TencentLoginType = DEFAULT_LOGIN_TYPE,
|
||||
): TencentLoginType {
|
||||
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
|
||||
if (nextLoginType === 'wx') {
|
||||
return 'wx'
|
||||
}
|
||||
|
||||
if (nextLoginType === 'qq') {
|
||||
return 'qq'
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveTaskStatusLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TaskStatusLabelContext = 'claim' | 'admin'
|
||||
|
||||
/**
|
||||
* Resolves a human-readable task status label.
|
||||
*
|
||||
* The `context` parameter controls wording differences between the
|
||||
* consumer-facing claim page and the admin manual-redeem page.
|
||||
*/
|
||||
export function resolveTaskStatusLabel(
|
||||
taskStatus: string | undefined,
|
||||
sessionStatus: string | undefined,
|
||||
context: TaskStatusLabelContext,
|
||||
): string {
|
||||
switch (taskStatus) {
|
||||
case 'link_generated':
|
||||
return context === 'admin' ? '待生成二维码' : '等待开始领取'
|
||||
case 'claimed':
|
||||
if (sessionStatus === 'scanned') {
|
||||
return context === 'admin' ? '客户待确认' : '确认中'
|
||||
}
|
||||
if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') {
|
||||
return context === 'admin' ? '已登录待复核' : '已登录'
|
||||
}
|
||||
return context === 'admin' ? '等待客户登录' : '领取中'
|
||||
case 'role_confirmed':
|
||||
return '角色已确认'
|
||||
case 'redeeming':
|
||||
return '正在兑换'
|
||||
case 'redeemed':
|
||||
return context === 'admin' ? '兑换完成' : '兑换成功'
|
||||
case 'waiting_inventory':
|
||||
return '等待库存'
|
||||
case 'retry_pending':
|
||||
return '等待重试'
|
||||
case 'manual_review':
|
||||
return '等待人工处理'
|
||||
case 'expired':
|
||||
return '链接已过期'
|
||||
case 'closed':
|
||||
return '任务已关闭'
|
||||
default:
|
||||
return sessionStatus || taskStatus || '等待中'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// roleFacts / resultFacts helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FactItem {
|
||||
label: string
|
||||
value: string
|
||||
accent?: boolean
|
||||
}
|
||||
|
||||
export interface UseSessionPresentationOptions {
|
||||
activityInfo: ComputedRef<{
|
||||
nickname?: string
|
||||
role?: {
|
||||
roleId?: string
|
||||
roleName?: string
|
||||
area?: string
|
||||
ready?: boolean
|
||||
} | null
|
||||
} | null>
|
||||
statusLabel: ComputedRef<string>
|
||||
/** Context-specific default values for roleFacts */
|
||||
roleFactDefaults: {
|
||||
nickname: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useSessionRoleFacts(options: UseSessionPresentationOptions) {
|
||||
const { activityInfo, statusLabel, roleFactDefaults } = options
|
||||
|
||||
const roleFacts = computed<FactItem[]>(() => [
|
||||
{
|
||||
label: '登录昵称',
|
||||
value: activityInfo.value?.nickname || roleFactDefaults.nickname,
|
||||
},
|
||||
{
|
||||
label: '当前角色',
|
||||
value: activityInfo.value?.role?.roleName || '后端浏览器同步中',
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
label: '角色 ID',
|
||||
value: activityInfo.value?.role?.roleId || '未识别',
|
||||
},
|
||||
{
|
||||
label: '所在大区',
|
||||
value: activityInfo.value?.role?.area || '未识别',
|
||||
},
|
||||
])
|
||||
|
||||
return { roleFacts }
|
||||
}
|
||||
|
||||
export interface UseSessionResultFactsOptions {
|
||||
order: ComputedRef<{ platformOrderId?: string } | null>
|
||||
orderItem: ComputedRef<{ skuName?: string } | null>
|
||||
result: ComputedRef<{ resultCode?: string; resultMessage?: string } | null>
|
||||
/** Admin context has manualRequest.proofValue as an alternative first label */
|
||||
resultFactOverrides?: {
|
||||
firstLabel?: string
|
||||
firstValue?: ComputedRef<string>
|
||||
}
|
||||
}
|
||||
|
||||
export function useSessionResultFacts(options: UseSessionResultFactsOptions) {
|
||||
const { order, orderItem, result, resultFactOverrides } = options
|
||||
|
||||
const resultFacts = computed<FactItem[]>(() => [
|
||||
{
|
||||
label: resultFactOverrides?.firstLabel || '订单号',
|
||||
value: resultFactOverrides?.firstValue?.value || order.value?.platformOrderId || '-',
|
||||
},
|
||||
{
|
||||
label: '商品',
|
||||
value: orderItem.value?.skuName || '-',
|
||||
},
|
||||
{
|
||||
label: '业务返回码',
|
||||
value: result.value?.resultCode || '-',
|
||||
},
|
||||
{
|
||||
label: '业务消息',
|
||||
value: result.value?.resultMessage || '尚未兑换',
|
||||
},
|
||||
])
|
||||
|
||||
return { resultFacts }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// redeemBlockedReason — builds the "why can't I redeem" explanation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildRedeemBlockedReason(options: {
|
||||
detail: ComputedRef<unknown | null>
|
||||
hasSession: ComputedRef<boolean>
|
||||
loginTypeLabel: ComputedRef<string>
|
||||
redeemLoading: ComputedRef<boolean>
|
||||
roleReady: ComputedRef<boolean>
|
||||
roleConfirmed: ComputedRef<boolean>
|
||||
sessionNotice: ComputedRef<string>
|
||||
context: 'claim' | 'admin'
|
||||
/** Claim-specific fields */
|
||||
tokenStatus?: ComputedRef<string>
|
||||
requiresSupportReview?: ComputedRef<boolean>
|
||||
}): string {
|
||||
const {
|
||||
detail,
|
||||
hasSession,
|
||||
loginTypeLabel,
|
||||
redeemLoading,
|
||||
roleReady,
|
||||
roleConfirmed,
|
||||
sessionNotice,
|
||||
context,
|
||||
tokenStatus,
|
||||
requiresSupportReview,
|
||||
} = options
|
||||
|
||||
if (context === 'claim') {
|
||||
if (!detail.value) {
|
||||
return '正在加载领取信息'
|
||||
}
|
||||
if (tokenStatus && tokenStatus.value !== 'active') {
|
||||
return '当前领取链接不可用'
|
||||
}
|
||||
} else {
|
||||
// admin context
|
||||
if (!detail.value) {
|
||||
return '请先创建人工兑换任务并预占库存'
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSession.value) {
|
||||
return context === 'claim'
|
||||
? `请先选择${loginTypeLabel.value}并初始化登录会话`
|
||||
: `请先生成${loginTypeLabel.value}二维码并等待客户登录`
|
||||
}
|
||||
|
||||
// Claim-specific: support review flow
|
||||
if (context === 'claim' && requiresSupportReview?.value) {
|
||||
if (!roleReady.value) {
|
||||
return '扫码成功后,系统会自动同步登录信息,准备发给客服复核'
|
||||
}
|
||||
return '当前商品需要客服复核角色并代你发起兑换,请联系人工继续'
|
||||
}
|
||||
|
||||
if (redeemLoading.value) {
|
||||
return '兑换任务正在执行中'
|
||||
}
|
||||
|
||||
if (!roleReady.value) {
|
||||
return (
|
||||
sessionNotice.value ||
|
||||
(context === 'claim'
|
||||
? '扫码成功后,后端正在同步角色和大区信息'
|
||||
: '客户登录后,系统会自动同步角色和大区信息')
|
||||
)
|
||||
}
|
||||
|
||||
if (!roleConfirmed.value) {
|
||||
return context === 'claim'
|
||||
? '请先确认当前角色与大区无误,再开始兑换'
|
||||
: '请先让客户确认角色截图,再点击确认当前角色'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { computed, ref, watch, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
/**
|
||||
* Shared QR code display logic: renders the base64 QR image, tracks its
|
||||
* natural width after load, and derives display/preview widths.
|
||||
*/
|
||||
export function useSessionQrDisplay(options: {
|
||||
session: ComputedRef<{ qrImageBase64?: string } | null>
|
||||
}) {
|
||||
const { session } = options
|
||||
|
||||
const qrImageNaturalWidth = ref(0)
|
||||
|
||||
const qrImage = computed(() =>
|
||||
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
|
||||
)
|
||||
|
||||
watch(qrImage, () => {
|
||||
qrImageNaturalWidth.value = 0
|
||||
})
|
||||
|
||||
function handleQrImageLoad(event: Event) {
|
||||
const target = event.target
|
||||
|
||||
if (!(target instanceof HTMLImageElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
qrImageNaturalWidth.value = target.naturalWidth || 0
|
||||
}
|
||||
|
||||
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 {
|
||||
qrImage,
|
||||
qrImageNaturalWidth,
|
||||
qrDisplayWidth,
|
||||
qrFigureStyle,
|
||||
qrPreviewWidth,
|
||||
handleQrImageLoad,
|
||||
}
|
||||
}
|
||||
@@ -51,20 +51,12 @@ export function useTencentBrowserSessionPolling(options: {
|
||||
const currentSession = session.value
|
||||
const response = await fetchTencentBrowserSessionSummary(currentSession.sessionId)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '获取浏览器会话状态失败')
|
||||
}
|
||||
|
||||
const nextSession = mergeSessionData(currentSession, response.data)
|
||||
session.value = nextSession
|
||||
|
||||
if (shouldRefreshQrImage(currentSession, response.data)) {
|
||||
const fullResponse = await fetchTencentBrowserSession(currentSession.sessionId)
|
||||
|
||||
if (fullResponse.code !== 0) {
|
||||
throw new Error(fullResponse.msg || '获取浏览器会话二维码失败')
|
||||
}
|
||||
|
||||
session.value = mergeSessionData(nextSession, fullResponse.data)
|
||||
}
|
||||
|
||||
@@ -140,7 +132,10 @@ export function useTencentBrowserSessionPolling(options: {
|
||||
return
|
||||
}
|
||||
|
||||
pollWarningMessage.value = `${resolveTencentActionMessage(error, '会话状态刷新失败')},已暂停自动轮询,请手动刷新或重新生成二维码。`
|
||||
pollWarningMessage.value = `${resolveTencentActionMessage(
|
||||
error,
|
||||
'会话状态刷新失败',
|
||||
)},已暂停自动轮询,请手动刷新或重新生成二维码。`
|
||||
resetPolling()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,22 +19,33 @@ import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/typ
|
||||
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'])
|
||||
import {
|
||||
useSessionPolling,
|
||||
useSessionQrDisplay,
|
||||
useSessionRoleFacts,
|
||||
useSessionResultFacts,
|
||||
buildRedeemBlockedReason,
|
||||
resolveTaskStatusLabel,
|
||||
syncLoginTypeFromDetail,
|
||||
mergeSessionDetail,
|
||||
shouldRefreshQrImage,
|
||||
shouldKeepPolling,
|
||||
DEFAULT_LOGIN_TYPE,
|
||||
} from './shared'
|
||||
|
||||
export function useAdminManualRedeemPage() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ── Form state (admin-only) ────────────────────────────────────────
|
||||
const form = reactive({
|
||||
proofValue: '',
|
||||
skuCode: '',
|
||||
remark: '',
|
||||
})
|
||||
const suggestions = ref<AdminInventorySkuSuggestion[]>([])
|
||||
|
||||
// ── Core state ────────────────────────────────────────────────────
|
||||
const detail = ref<AdminManualRedeemDetail | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const createLoading = ref(false)
|
||||
@@ -42,17 +53,14 @@ export function useAdminManualRedeemPage() {
|
||||
const roleConfirmLoading = ref(false)
|
||||
const redeemLoading = ref(false)
|
||||
const closeLoading = ref(false)
|
||||
const pollWarningMessage = ref('')
|
||||
const qrImageNaturalWidth = ref(0)
|
||||
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
|
||||
|
||||
// ── Screenshot state (admin-only) ──────────────────────────────────
|
||||
const screenshotBlob = ref<Blob | null>(null)
|
||||
const screenshotUrl = ref('')
|
||||
const screenshotLoading = ref(false)
|
||||
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pollToken = 0
|
||||
let pollFailureCount = 0
|
||||
|
||||
// ── Derived state ──────────────────────────────────────────────────
|
||||
const session = computed(() => detail.value?.session || null)
|
||||
const task = computed(() => detail.value?.task || null)
|
||||
const order = computed(() => detail.value?.order || null)
|
||||
@@ -66,12 +74,6 @@ export function useAdminManualRedeemPage() {
|
||||
)
|
||||
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',
|
||||
@@ -79,19 +81,67 @@ export function useAdminManualRedeemPage() {
|
||||
const resultReady = computed(
|
||||
() => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed',
|
||||
)
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────
|
||||
const {
|
||||
pollWarningMessage,
|
||||
startPolling,
|
||||
resetPolling,
|
||||
resetPollingWarning,
|
||||
handleSilentPollingError,
|
||||
} = useSessionPolling({
|
||||
hasSession: computed(() => Boolean(task.value?.taskId && hasSession.value)),
|
||||
shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)),
|
||||
onPollTick: () => refreshSessionSummary({ silent: true }),
|
||||
pollWarningPrefix: '人工兑换状态刷新失败',
|
||||
})
|
||||
|
||||
const sessionNotice = computed(
|
||||
() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '',
|
||||
)
|
||||
|
||||
// ── QR display ─────────────────────────────────────────────────────
|
||||
const {
|
||||
qrImage,
|
||||
qrFigureStyle,
|
||||
qrPreviewWidth,
|
||||
handleQrImageLoad,
|
||||
} = useSessionQrDisplay({ session })
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────
|
||||
const loginTabs = [
|
||||
{ value: 'qq' as const, label: 'QQ账号登录' },
|
||||
{ value: 'wx' as const, label: '微信账号登录' },
|
||||
]
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
resolveTaskStatusLabel(task.value?.status, session.value?.status),
|
||||
resolveTaskStatusLabel(task.value?.status, session.value?.status, 'admin'),
|
||||
)
|
||||
const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`)
|
||||
const scanInstruction = computed(
|
||||
() => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`,
|
||||
)
|
||||
const roleFacts = computed(() => [
|
||||
|
||||
const { roleFacts } = useSessionRoleFacts({
|
||||
activityInfo,
|
||||
statusLabel,
|
||||
roleFactDefaults: { nickname: '等待客户登录' },
|
||||
})
|
||||
|
||||
const proofValue = computed(() => manualRequest.value?.proofValue || order.value?.platformOrderId || '-')
|
||||
|
||||
const { resultFacts } = useSessionResultFacts({
|
||||
order,
|
||||
orderItem,
|
||||
result,
|
||||
resultFactOverrides: {
|
||||
firstLabel: '唯一凭据',
|
||||
firstValue: proofValue,
|
||||
},
|
||||
})
|
||||
|
||||
// Admin overrides roleFacts to include "所在大区" and exclude "任务状态"
|
||||
const adminRoleFacts = computed(() => [
|
||||
{
|
||||
label: '登录昵称',
|
||||
value: activityInfo.value?.nickname || '等待客户登录',
|
||||
@@ -110,24 +160,7 @@ export function useAdminManualRedeemPage() {
|
||||
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 &&
|
||||
@@ -144,29 +177,18 @@ export function useAdminManualRedeemPage() {
|
||||
!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 redeemBlockedReason = computed(() =>
|
||||
buildRedeemBlockedReason({
|
||||
detail,
|
||||
hasSession,
|
||||
loginTypeLabel,
|
||||
redeemLoading,
|
||||
roleReady,
|
||||
roleConfirmed,
|
||||
sessionNotice,
|
||||
context: 'admin',
|
||||
}),
|
||||
)
|
||||
const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换'))
|
||||
const screenshotEmptyTitle = computed(() =>
|
||||
task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图',
|
||||
@@ -181,8 +203,9 @@ export function useAdminManualRedeemPage() {
|
||||
)
|
||||
const hasActiveTask = computed(() => Boolean(task.value?.taskId))
|
||||
|
||||
// ── Watchers ───────────────────────────────────────────────────────
|
||||
watch(qrImage, () => {
|
||||
qrImageNaturalWidth.value = 0
|
||||
// QR image natural width reset handled by useSessionQrDisplay
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -206,6 +229,85 @@ export function useAdminManualRedeemPage() {
|
||||
clearScreenshotPreview()
|
||||
})
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
function applyLoginTypeFromDetail(nextDetail: AdminManualRedeemDetail) {
|
||||
loginType.value = syncLoginTypeFromDetail(nextDetail, loginType.value)
|
||||
}
|
||||
|
||||
function restartPollingIfNeeded(nextDetail = detail.value) {
|
||||
resetPolling()
|
||||
|
||||
if (shouldKeepPolling(nextDetail)) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSkuName(skuCode: string) {
|
||||
const normalized = String(skuCode || '').trim()
|
||||
const matched = suggestions.value.find(
|
||||
(item) => String(item.skuCode || '').trim() === normalized,
|
||||
)
|
||||
return matched?.skuCode || normalized
|
||||
}
|
||||
|
||||
// ── Screenshot helpers (admin-only) ───────────────────────────────
|
||||
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 = ''
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── API flows ─────────────────────────────────────────────────────
|
||||
async function loadSkuSuggestions(keyword = '') {
|
||||
try {
|
||||
const response = await fetchAdminManualRedeemSkuSuggestions({
|
||||
@@ -375,12 +477,24 @@ export function useAdminManualRedeemPage() {
|
||||
try {
|
||||
const currentSession = detail.value?.session || null
|
||||
const response = await fetchAdminManualRedeemSessionSummary(task.value.taskId)
|
||||
detail.value = mergeDetail(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(
|
||||
detail.value,
|
||||
response.data,
|
||||
(currentSession, nextSession) => ({
|
||||
review: nextSession.review ?? currentSession.review ?? null,
|
||||
}),
|
||||
)
|
||||
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)
|
||||
const fullResponse = await fetchAdminManualRedeemDetail(task.value.taskId!)
|
||||
detail.value = mergeSessionDetail(
|
||||
detail.value,
|
||||
fullResponse.data,
|
||||
(currentSession, nextSession) => ({
|
||||
review: nextSession.review ?? currentSession.review ?? null,
|
||||
}),
|
||||
)
|
||||
loginType.value = syncLoginTypeFromDetail(fullResponse.data, loginType.value)
|
||||
}
|
||||
|
||||
@@ -503,16 +617,6 @@ export function useAdminManualRedeemPage() {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -560,151 +664,6 @@ export function useAdminManualRedeemPage() {
|
||||
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,
|
||||
@@ -732,7 +691,7 @@ export function useAdminManualRedeemPage() {
|
||||
qrPreviewWidth,
|
||||
statusLabel,
|
||||
sessionNotice,
|
||||
roleFacts,
|
||||
roleFacts: adminRoleFacts,
|
||||
resultFacts,
|
||||
canConfirmRole,
|
||||
canRedeem,
|
||||
@@ -764,100 +723,3 @@ export function useAdminManualRedeemPage() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -10,19 +10,23 @@ import {
|
||||
removeClaimSession,
|
||||
redeemClaim,
|
||||
} from '@/services/claim'
|
||||
import type { ClaimDetailData, ClaimTaskStatus } from '@/types/claim'
|
||||
import type {
|
||||
TencentBrowserSessionData,
|
||||
TencentBrowserSessionSummaryData,
|
||||
TencentLoginType,
|
||||
} from '@/types/tencent/session'
|
||||
import type { ClaimDetailData } from '@/types/claim'
|
||||
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<ClaimTaskStatus>(['claimed', 'role_confirmed', 'redeeming'])
|
||||
import {
|
||||
useSessionPolling,
|
||||
useSessionQrDisplay,
|
||||
useSessionRoleFacts,
|
||||
useSessionResultFacts,
|
||||
buildRedeemBlockedReason,
|
||||
resolveTaskStatusLabel,
|
||||
syncLoginTypeFromDetail,
|
||||
mergeSessionDetail,
|
||||
shouldRefreshQrImage,
|
||||
shouldKeepPolling,
|
||||
DEFAULT_LOGIN_TYPE,
|
||||
} from './shared'
|
||||
|
||||
export function useClaimPage(token: string) {
|
||||
const detailLoading = ref(true)
|
||||
@@ -32,13 +36,8 @@ export function useClaimPage(token: string) {
|
||||
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
|
||||
const detail = ref<ClaimDetailData | null>(null)
|
||||
const roleConfirmed = ref(false)
|
||||
const pollWarningMessage = ref('')
|
||||
const qrImageNaturalWidth = ref(0)
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pollToken = 0
|
||||
let pollFailureCount = 0
|
||||
|
||||
// ── Derived state ──────────────────────────────────────────────────
|
||||
const session = computed(() => detail.value?.session || null)
|
||||
const task = computed(() => detail.value?.task || null)
|
||||
const order = computed(() => detail.value?.order || null)
|
||||
@@ -49,12 +48,31 @@ export function useClaimPage(token: string) {
|
||||
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
|
||||
const hasSession = computed(() => Boolean(session.value?.sessionId))
|
||||
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────
|
||||
const {
|
||||
pollWarningMessage,
|
||||
startPolling,
|
||||
resetPolling,
|
||||
resetPollingWarning,
|
||||
handleSilentPollingError,
|
||||
} = useSessionPolling({
|
||||
hasSession,
|
||||
shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)),
|
||||
onPollTick: () => refreshSessionSummary({ silent: true }),
|
||||
pollWarningPrefix: '领取状态刷新失败',
|
||||
})
|
||||
|
||||
const sessionNotice = computed(
|
||||
() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '',
|
||||
)
|
||||
const qrImage = computed(() =>
|
||||
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
|
||||
)
|
||||
|
||||
// ── QR display ─────────────────────────────────────────────────────
|
||||
const { qrImage, qrFigureStyle, qrPreviewWidth, handleQrImageLoad } = useSessionQrDisplay({
|
||||
session,
|
||||
})
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────
|
||||
const screenshotUrl = computed(() => result.value?.screenshotUrl || '')
|
||||
const showScreenshot = computed(() => Boolean(screenshotUrl.value))
|
||||
const loginTabs = [
|
||||
@@ -63,13 +81,22 @@ export function useClaimPage(token: string) {
|
||||
]
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
resolveTaskStatusLabel(task.value?.status, session.value?.status),
|
||||
resolveTaskStatusLabel(task.value?.status, session.value?.status, 'claim'),
|
||||
)
|
||||
const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`)
|
||||
const scanInstruction = computed(
|
||||
() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`,
|
||||
)
|
||||
const roleFacts = computed(() => [
|
||||
|
||||
const { roleFacts } = useSessionRoleFacts({
|
||||
activityInfo,
|
||||
statusLabel,
|
||||
roleFactDefaults: { nickname: '等待扫码登录' },
|
||||
})
|
||||
|
||||
// Claim pages show 4 role facts, but original only had 3 (no area).
|
||||
// Override roleFacts to match original exactly:
|
||||
const claimRoleFacts = computed(() => [
|
||||
{
|
||||
label: '登录昵称',
|
||||
value: activityInfo.value?.nickname || '等待扫码登录',
|
||||
@@ -88,78 +115,46 @@ export function useClaimPage(token: string) {
|
||||
value: statusLabel.value,
|
||||
},
|
||||
])
|
||||
const resultFacts = computed(() => [
|
||||
{
|
||||
label: '订单号',
|
||||
value: order.value?.platformOrderId || '-',
|
||||
},
|
||||
{
|
||||
label: '商品',
|
||||
value: orderItem.value?.skuName || '-',
|
||||
},
|
||||
{
|
||||
label: '业务返回码',
|
||||
value: result.value?.resultCode || '-',
|
||||
},
|
||||
{
|
||||
label: '业务消息',
|
||||
value: result.value?.resultMessage || '尚未兑换',
|
||||
},
|
||||
])
|
||||
|
||||
const { resultFacts } = useSessionResultFacts({
|
||||
order,
|
||||
orderItem,
|
||||
result,
|
||||
})
|
||||
|
||||
const canConfirmRole = computed(() =>
|
||||
Boolean(
|
||||
task.value &&
|
||||
!task.value.requiresSupportReview &&
|
||||
hasSession.value &&
|
||||
roleReady.value &&
|
||||
task.value.status === 'claimed',
|
||||
!task.value.requiresSupportReview &&
|
||||
hasSession.value &&
|
||||
roleReady.value &&
|
||||
task.value.status === 'claimed',
|
||||
),
|
||||
)
|
||||
const canRedeem = computed(() =>
|
||||
Boolean(
|
||||
task.value &&
|
||||
!task.value.requiresSupportReview &&
|
||||
hasSession.value &&
|
||||
roleConfirmed.value &&
|
||||
!redeemLoading.value &&
|
||||
(task.value.status === 'role_confirmed' || task.value.status === 'redeeming'),
|
||||
!task.value.requiresSupportReview &&
|
||||
hasSession.value &&
|
||||
roleConfirmed.value &&
|
||||
!redeemLoading.value &&
|
||||
(task.value.status === 'role_confirmed' || task.value.status === 'redeeming'),
|
||||
),
|
||||
)
|
||||
const redeemBlockedReason = computed(() => {
|
||||
if (!detail.value) {
|
||||
return '正在加载领取信息'
|
||||
}
|
||||
|
||||
if (tokenStatus.value !== 'active') {
|
||||
return '当前领取链接不可用'
|
||||
}
|
||||
|
||||
if (!hasSession.value) {
|
||||
return `请先选择${loginTypeLabel.value}并初始化登录会话`
|
||||
}
|
||||
|
||||
if (task.value?.requiresSupportReview) {
|
||||
if (!roleReady.value) {
|
||||
return '扫码成功后,系统会自动同步登录信息,准备发给客服复核'
|
||||
}
|
||||
|
||||
return '当前商品需要客服复核角色并代你发起兑换,请联系人工继续'
|
||||
}
|
||||
|
||||
if (redeemLoading.value) {
|
||||
return '兑换任务正在执行中'
|
||||
}
|
||||
|
||||
if (!roleReady.value) {
|
||||
return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息'
|
||||
}
|
||||
|
||||
if (!roleConfirmed.value) {
|
||||
return '请先确认当前角色与大区无误,再开始兑换'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
const redeemBlockedReason = computed(() =>
|
||||
buildRedeemBlockedReason({
|
||||
detail,
|
||||
hasSession,
|
||||
loginTypeLabel,
|
||||
redeemLoading,
|
||||
roleReady,
|
||||
roleConfirmed,
|
||||
sessionNotice,
|
||||
context: 'claim',
|
||||
tokenStatus,
|
||||
requiresSupportReview: computed(() => Boolean(task.value?.requiresSupportReview)),
|
||||
}),
|
||||
)
|
||||
const redeemButtonLabel = computed(() =>
|
||||
task.value?.requiresSupportReview ? '等待客服兑换' : '开始兑换',
|
||||
)
|
||||
@@ -172,10 +167,7 @@ export function useClaimPage(token: string) {
|
||||
: '领取完成后,如本次生成了结果截图,这里会展示。',
|
||||
)
|
||||
|
||||
function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) {
|
||||
loginType.value = syncLoginTypeFromDetail(nextDetail, loginType.value)
|
||||
}
|
||||
|
||||
// ── Watchers ───────────────────────────────────────────────────────
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
@@ -189,28 +181,34 @@ export function useClaimPage(token: string) {
|
||||
() => {
|
||||
roleConfirmed.value = Boolean(
|
||||
task.value?.status === 'role_confirmed' ||
|
||||
task.value?.status === 'redeeming' ||
|
||||
task.value?.status === 'redeemed',
|
||||
task.value?.status === 'redeeming' ||
|
||||
task.value?.status === 'redeemed',
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(qrImage, () => {
|
||||
qrImageNaturalWidth.value = 0
|
||||
})
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) {
|
||||
loginType.value = syncLoginTypeFromDetail(nextDetail, loginType.value)
|
||||
}
|
||||
|
||||
function restartPollingIfNeeded(nextDetail = detail.value) {
|
||||
resetPolling()
|
||||
|
||||
if (shouldKeepPolling(nextDetail)) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
// ── API flows ─────────────────────────────────────────────────────
|
||||
async function loadDetail() {
|
||||
detailLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetchClaimDetail(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '领取详情加载失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
restartPollingIfNeeded()
|
||||
} catch (error) {
|
||||
@@ -231,11 +229,7 @@ export function useClaimPage(token: string) {
|
||||
forceRecreate: hasSession.value,
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '创建领取会话失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
startPolling()
|
||||
} catch (error) {
|
||||
@@ -257,11 +251,7 @@ export function useClaimPage(token: string) {
|
||||
try {
|
||||
const response = await refreshClaimSession(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '刷新后端页面失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
restartPollingIfNeeded(response.data)
|
||||
return true
|
||||
@@ -290,11 +280,7 @@ export function useClaimPage(token: string) {
|
||||
try {
|
||||
const response = await removeClaimSession(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '关闭领取会话失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
|
||||
if (!silent) {
|
||||
@@ -328,22 +314,14 @@ export function useClaimPage(token: string) {
|
||||
const currentSession = detail.value?.session || null
|
||||
const response = await fetchClaimSessionSummary(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '领取会话状态刷新失败')
|
||||
}
|
||||
|
||||
let nextDetail = mergeClaimDetailData(detail.value, response.data)
|
||||
let nextDetail = mergeSessionDetail(detail.value, response.data)
|
||||
detail.value = nextDetail
|
||||
applyLoginTypeFromDetail(nextDetail)
|
||||
|
||||
if (shouldRefreshClaimQrImage(currentSession, response.data)) {
|
||||
if (shouldRefreshQrImage(currentSession, response.data.session)) {
|
||||
const fullResponse = await fetchClaimDetail(token)
|
||||
|
||||
if (fullResponse.code !== 0) {
|
||||
throw new Error(fullResponse.msg || '领取二维码刷新失败')
|
||||
}
|
||||
|
||||
nextDetail = mergeClaimDetailData(nextDetail, fullResponse.data)
|
||||
nextDetail = mergeSessionDetail(nextDetail, fullResponse.data)
|
||||
detail.value = nextDetail
|
||||
applyLoginTypeFromDetail(nextDetail)
|
||||
}
|
||||
@@ -378,11 +356,7 @@ export function useClaimPage(token: string) {
|
||||
try {
|
||||
const response = await confirmClaimRole(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '角色确认失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
roleConfirmed.value = true
|
||||
showSuccess(response.msg || '角色已确认')
|
||||
restartPollingIfNeeded(response.data)
|
||||
@@ -406,11 +380,7 @@ export function useClaimPage(token: string) {
|
||||
try {
|
||||
const response = await redeemClaim(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '领取兑换失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
detail.value = mergeSessionDetail(detail.value, response.data)
|
||||
showSuccess(response.msg || '兑换完成')
|
||||
restartPollingIfNeeded(response.data)
|
||||
} catch (error) {
|
||||
@@ -435,97 +405,7 @@ export function useClaimPage(token: string) {
|
||||
loginType.value = nextLoginType
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
const tokenId = ++pollToken
|
||||
|
||||
const loop = async () => {
|
||||
if (tokenId !== pollToken || !hasSession.value) {
|
||||
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()
|
||||
}
|
||||
|
||||
function handleQrImageLoad(event: Event) {
|
||||
const target = event.target
|
||||
|
||||
if (!(target instanceof HTMLImageElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
qrImageNaturalWidth.value = target.naturalWidth || 0
|
||||
}
|
||||
|
||||
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`,
|
||||
)
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────
|
||||
loadDetail()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -552,7 +432,7 @@ export function useClaimPage(token: string) {
|
||||
statusLabel,
|
||||
session,
|
||||
sessionNotice,
|
||||
roleFacts,
|
||||
roleFacts: claimRoleFacts,
|
||||
resultFacts,
|
||||
roleConfirmed,
|
||||
roleReady,
|
||||
@@ -576,114 +456,3 @@ export function useClaimPage(token: string) {
|
||||
handleQrImageLoad,
|
||||
}
|
||||
}
|
||||
|
||||
function syncLoginTypeFromDetail(
|
||||
detail: ClaimDetailData,
|
||||
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: ClaimDetailData | null) {
|
||||
if (!detail?.session?.sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ACTIVE_TASK_STATUSES.has(detail.task.status)
|
||||
}
|
||||
|
||||
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 'manual_review':
|
||||
return '等待人工处理'
|
||||
case 'expired':
|
||||
return '链接已过期'
|
||||
case 'closed':
|
||||
return '任务已关闭'
|
||||
default:
|
||||
return sessionStatus || taskStatus || '等待中'
|
||||
}
|
||||
}
|
||||
|
||||
function mergeClaimDetailData(current: ClaimDetailData | null, next: ClaimDetailData) {
|
||||
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,
|
||||
redeem: next.session.redeem ?? current.session.redeem ?? null,
|
||||
artifacts: next.session.artifacts || current.session.artifacts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshClaimQrImage(
|
||||
currentSession: ClaimDetailData['session'],
|
||||
nextDetail: ClaimDetailData,
|
||||
) {
|
||||
if (!currentSession || !nextDetail.session) {
|
||||
return false
|
||||
}
|
||||
|
||||
return shouldRefreshQrImage(currentSession, nextDetail.session)
|
||||
}
|
||||
|
||||
function shouldRefreshQrImage(
|
||||
current: TencentBrowserSessionData | TencentBrowserSessionSummaryData,
|
||||
next: TencentBrowserSessionData | TencentBrowserSessionSummaryData,
|
||||
) {
|
||||
if (!next.artifacts?.hasQrImage) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!current.qrImageBase64) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt)
|
||||
}
|
||||
|
||||
@@ -100,10 +100,6 @@ export function useTencentBrowserSessionPage() {
|
||||
loginType: nextLoginType,
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '创建浏览器会话失败')
|
||||
}
|
||||
|
||||
session.value = response.data
|
||||
startPolling()
|
||||
} catch (error) {
|
||||
@@ -125,10 +121,6 @@ export function useTencentBrowserSessionPage() {
|
||||
try {
|
||||
const response = await refreshTencentBrowserSessionPage(session.value.sessionId)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '刷新后端页面失败')
|
||||
}
|
||||
|
||||
session.value = response.data
|
||||
restartPollingIfActive(response.data)
|
||||
} catch (error) {
|
||||
@@ -173,10 +165,6 @@ export function useTencentBrowserSessionPage() {
|
||||
maxAttempts: maxAttempts.value,
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '浏览器兑换失败')
|
||||
}
|
||||
|
||||
session.value = response.data
|
||||
showSuccess(response.msg || '兑换完成')
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user