init
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export type TencentActionError = Error & {
|
||||
errorCode?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export function notifyTencentActionError(error: unknown, fallbackMessage: string) {
|
||||
const message = resolveTencentActionMessage(error, fallbackMessage)
|
||||
console.error(error)
|
||||
ElMessage.error(message)
|
||||
}
|
||||
|
||||
export function resolveTencentActionMessage(error: unknown, fallbackMessage: string) {
|
||||
const normalizedError = error as TencentActionError
|
||||
const errorCode = String(normalizedError?.errorCode || '').trim()
|
||||
const message = normalizedError instanceof Error ? normalizedError.message.trim() : ''
|
||||
|
||||
if (errorCode === 'missing_redeem_code') {
|
||||
return '请输入兑换码'
|
||||
}
|
||||
|
||||
if (errorCode === 'session_not_ready') {
|
||||
return '当前会话还不能兑换,请先完成扫码并确认角色信息'
|
||||
}
|
||||
|
||||
if (errorCode === 'session_not_found' || errorCode === 'not_found') {
|
||||
return '当前会话不存在或已过期,请重新生成二维码'
|
||||
}
|
||||
|
||||
if (errorCode === 'session_closed' || errorCode === 'gone') {
|
||||
return '当前会话已关闭,请重新生成二维码'
|
||||
}
|
||||
|
||||
if (errorCode === 'dependency_unavailable') {
|
||||
return '后端依赖暂不可用,请检查浏览器或 OCR 服务是否已就绪'
|
||||
}
|
||||
|
||||
if (errorCode === 'invalid_request') {
|
||||
return '请求参数不完整,请检查输入后重试'
|
||||
}
|
||||
|
||||
if (errorCode === 'conflict') {
|
||||
return '当前会话状态已变化,请刷新后重试'
|
||||
}
|
||||
|
||||
return message || fallbackMessage
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
|
||||
import {
|
||||
fetchTencentBrowserSession,
|
||||
fetchTencentBrowserSessionSummary,
|
||||
} from '@/services/tencent/session'
|
||||
import type {
|
||||
TencentBrowserSessionData,
|
||||
TencentBrowserSessionStatus,
|
||||
TencentBrowserSessionSummaryData,
|
||||
} from '@/types/tencent/session'
|
||||
|
||||
import { resolveTencentActionMessage } from './session-errors'
|
||||
|
||||
const POLL_INTERVAL_MS = 2_500
|
||||
const POLL_FAILURE_LIMIT = 3
|
||||
|
||||
export const ACTIVE_TENCENT_SESSION_STATUSES = new Set([
|
||||
'waiting_scan',
|
||||
'scanned',
|
||||
'logged_in',
|
||||
'ready_to_redeem',
|
||||
'redeeming',
|
||||
])
|
||||
|
||||
export function useTencentBrowserSessionPolling(options: {
|
||||
session: Ref<TencentBrowserSessionData | null>
|
||||
sessionLoading: Ref<boolean>
|
||||
notifyActionError: (error: unknown, fallbackMessage: string) => void
|
||||
}) {
|
||||
const { session, sessionLoading, notifyActionError } = options
|
||||
const pollFailureCount = ref(0)
|
||||
const pollWarningMessage = ref('')
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pollToken = 0
|
||||
|
||||
const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || '')
|
||||
|
||||
async function refreshSession({ silent = false } = {}) {
|
||||
if (!session.value?.sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
sessionLoading.value = true
|
||||
resetPollingWarning()
|
||||
}
|
||||
|
||||
try {
|
||||
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)
|
||||
}
|
||||
|
||||
if (silent) {
|
||||
resetPollingWarning()
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
if (silent) {
|
||||
handleSilentPollingError(error)
|
||||
} else {
|
||||
notifyActionError(error, '获取浏览器会话状态失败')
|
||||
}
|
||||
|
||||
return false
|
||||
} finally {
|
||||
if (!silent) {
|
||||
sessionLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
const token = ++pollToken
|
||||
|
||||
const loop = async () => {
|
||||
if (token !== pollToken || !session.value?.sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
await refreshSession({ silent: true })
|
||||
|
||||
if (token !== pollToken || !isActiveTencentSessionStatus(session.value?.status)) {
|
||||
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.value = 0
|
||||
pollWarningMessage.value = ''
|
||||
}
|
||||
|
||||
function restartPollingIfActive(nextSession = session.value) {
|
||||
if (isActiveTencentSessionStatus(nextSession?.status)) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSilentPollingError(error: unknown) {
|
||||
console.error(error)
|
||||
pollFailureCount.value += 1
|
||||
|
||||
if (pollFailureCount.value < POLL_FAILURE_LIMIT) {
|
||||
return
|
||||
}
|
||||
|
||||
pollWarningMessage.value = `${resolveTencentActionMessage(error, '会话状态刷新失败')},已暂停自动轮询,请手动刷新或重新生成二维码。`
|
||||
resetPolling()
|
||||
}
|
||||
|
||||
return {
|
||||
refreshSession,
|
||||
resetPolling,
|
||||
resetPollingWarning,
|
||||
restartPollingIfActive,
|
||||
sessionNotice,
|
||||
startPolling,
|
||||
}
|
||||
}
|
||||
|
||||
export function isActiveTencentSessionStatus(status: TencentBrowserSessionStatus | null | undefined) {
|
||||
return Boolean(status && ACTIVE_TENCENT_SESSION_STATUSES.has(status))
|
||||
}
|
||||
|
||||
function mergeSessionData(
|
||||
current: TencentBrowserSessionData | null,
|
||||
next: TencentBrowserSessionSummaryData | TencentBrowserSessionData,
|
||||
) {
|
||||
if (!current) {
|
||||
return {
|
||||
...next,
|
||||
qrImageBase64: next.qrImageBase64 || '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
...next,
|
||||
qrImageBase64:
|
||||
typeof next.qrImageBase64 === 'string' ? next.qrImageBase64 : current.qrImageBase64,
|
||||
activityInfo: next.activityInfo ?? current.activityInfo ?? null,
|
||||
redeem: next.redeem ?? current.redeem ?? null,
|
||||
artifacts: next.artifacts || current.artifacts,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshQrImage(
|
||||
current: TencentBrowserSessionData,
|
||||
next: TencentBrowserSessionSummaryData,
|
||||
) {
|
||||
if (!next.artifacts?.hasQrImage) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!current.qrImageBase64) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import { buildTencentBrowserSessionScreenshotUrl } from '@/services/tencent/artifacts'
|
||||
import type { TencentBrowserActivityInfo } from '@/types/tencent/activity'
|
||||
import type { TencentBrowserSessionData } from '@/types/tencent/session'
|
||||
|
||||
const REDEEM_ALLOWED_STATUSES = new Set(['logged_in', 'ready_to_redeem', 'redeemed'])
|
||||
|
||||
export function useTencentBrowserSessionPresentation(options: {
|
||||
activityInfo: ComputedRef<TencentBrowserActivityInfo | null>
|
||||
hasSession: ComputedRef<boolean>
|
||||
loginTypeLabel: ComputedRef<string>
|
||||
redeemLoading: Ref<boolean>
|
||||
roleConfirmed: Ref<boolean>
|
||||
roleReady: ComputedRef<boolean>
|
||||
session: Ref<TencentBrowserSessionData | null>
|
||||
sessionLoading: Ref<boolean>
|
||||
sessionNotice: ComputedRef<string>
|
||||
}) {
|
||||
const {
|
||||
activityInfo,
|
||||
hasSession,
|
||||
loginTypeLabel,
|
||||
redeemLoading,
|
||||
roleConfirmed,
|
||||
roleReady,
|
||||
session,
|
||||
sessionLoading,
|
||||
sessionNotice,
|
||||
} = options
|
||||
|
||||
const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`)
|
||||
const scanInstruction = computed(() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`)
|
||||
|
||||
const qrImage = computed(() =>
|
||||
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
|
||||
)
|
||||
|
||||
const screenshotUrl = computed(() => {
|
||||
if (!session.value?.sessionId || !session.value?.artifacts?.hasScreenshot) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return buildTencentBrowserSessionScreenshotUrl(session.value.sessionId, session.value.updatedAt)
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (!hasSession.value) {
|
||||
return '待初始化'
|
||||
}
|
||||
|
||||
switch (session.value?.status) {
|
||||
case 'waiting_scan':
|
||||
return '等待扫码'
|
||||
case 'scanned':
|
||||
return '已扫码待确认'
|
||||
case 'logged_in':
|
||||
return '页面已登录'
|
||||
case 'ready_to_redeem':
|
||||
return '可以兑换'
|
||||
case 'redeeming':
|
||||
return '兑换中'
|
||||
case 'redeemed':
|
||||
return '已完成'
|
||||
case 'failed':
|
||||
return '会话异常'
|
||||
default:
|
||||
return '初始化中'
|
||||
}
|
||||
})
|
||||
|
||||
const canRedeem = computed(() => {
|
||||
if (redeemLoading.value || sessionLoading.value || !session.value?.sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
REDEEM_ALLOWED_STATUSES.has(String(session.value.status || '')) &&
|
||||
roleReady.value &&
|
||||
roleConfirmed.value
|
||||
)
|
||||
})
|
||||
|
||||
const redeemBlockedReason = computed(() => {
|
||||
if (!session.value?.sessionId) {
|
||||
return `请先选择${loginTypeLabel.value}登录,并手动开始初始化浏览器会话`
|
||||
}
|
||||
|
||||
if (redeemLoading.value) {
|
||||
return '兑换任务正在执行中'
|
||||
}
|
||||
|
||||
if (sessionLoading.value) {
|
||||
return '正在刷新浏览器会话状态'
|
||||
}
|
||||
|
||||
if (!roleReady.value) {
|
||||
return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息'
|
||||
}
|
||||
|
||||
if (!roleConfirmed.value) {
|
||||
return '请先确认当前角色与大区无误,再开始兑换'
|
||||
}
|
||||
|
||||
if (REDEEM_ALLOWED_STATUSES.has(String(session.value.status || ''))) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (session.value.status) {
|
||||
case 'waiting_scan':
|
||||
return `请先使用${loginTypeLabel.value}扫码`
|
||||
case 'scanned':
|
||||
return '请在手机上确认登录后再兑换'
|
||||
case 'failed':
|
||||
return sessionNotice.value || '浏览器会话异常,请重新生成二维码'
|
||||
default:
|
||||
return sessionNotice.value || '当前状态还不能开始兑换'
|
||||
}
|
||||
})
|
||||
|
||||
const finalRedeemMessage = computed(() => {
|
||||
const result = session.value?.redeem?.final?.redeem
|
||||
|
||||
if (!result || typeof result !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const record = result as Record<string, unknown>
|
||||
return String(record.sMsg || record.msg || '')
|
||||
})
|
||||
|
||||
const finalRetCode = computed(() => {
|
||||
const result = session.value?.redeem?.final?.redeem
|
||||
|
||||
if (!result || typeof result !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const record = result as Record<string, unknown>
|
||||
return String(record.iRet ?? '')
|
||||
})
|
||||
|
||||
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?.form?.verifyValue ||
|
||||
(activityInfo.value?.verify?.visible ? '等待识别' : '等待显示'),
|
||||
},
|
||||
])
|
||||
|
||||
const resultFacts = computed(() => [
|
||||
{
|
||||
label: '业务返回码',
|
||||
value: finalRetCode.value || '-',
|
||||
},
|
||||
{
|
||||
label: '业务消息',
|
||||
value: finalRedeemMessage.value || '尚未兑换',
|
||||
},
|
||||
{
|
||||
label: 'OCR 尝试次数',
|
||||
value: String(session.value?.redeem?.attempts.length || 0),
|
||||
},
|
||||
{
|
||||
label: '证明截图',
|
||||
value: session.value?.artifacts?.hasScreenshot ? '已生成' : '未生成',
|
||||
},
|
||||
])
|
||||
|
||||
const redeemButtonLabel = computed(() => '开始兑换')
|
||||
const screenshotEmptyTitle = computed(() =>
|
||||
session.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图',
|
||||
)
|
||||
const screenshotEmptyMessage = computed(() =>
|
||||
session.value?.status === 'redeemed'
|
||||
? '本次兑换可能未生成截图,或截图产物已被后端配置关闭。'
|
||||
: '如本次兑换生成了结果截图,这里会展示。'
|
||||
)
|
||||
|
||||
return {
|
||||
canRedeem,
|
||||
initButtonLabel,
|
||||
qrImage,
|
||||
redeemBlockedReason,
|
||||
redeemButtonLabel,
|
||||
resultFacts,
|
||||
roleFacts,
|
||||
scanInstruction,
|
||||
screenshotEmptyMessage,
|
||||
screenshotEmptyTitle,
|
||||
screenshotUrl,
|
||||
statusLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
confirmClaimRole,
|
||||
createClaimSession,
|
||||
fetchClaimDetail,
|
||||
fetchClaimSessionSummary,
|
||||
redeemClaim,
|
||||
} from '@/services/claim'
|
||||
import type { ClaimDetailData, ClaimTaskStatus } 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',
|
||||
])
|
||||
|
||||
export function useClaimPage(token: string) {
|
||||
const detailLoading = ref(true)
|
||||
const sessionLoading = ref(false)
|
||||
const redeemLoading = ref(false)
|
||||
const roleConfirmLoading = ref(false)
|
||||
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
|
||||
|
||||
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 tokenStatus = computed(() => detail.value?.tokenStatus || 'active')
|
||||
const activityInfo = computed(() => session.value?.activityInfo || null)
|
||||
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
|
||||
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 screenshotUrl = computed(() => result.value?.screenshotUrl || '')
|
||||
const showScreenshot = computed(() => Boolean(screenshotUrl.value))
|
||||
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: 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 canConfirmRole = computed(() =>
|
||||
Boolean(task.value && hasSession.value && roleReady.value && task.value.status === 'claimed'),
|
||||
)
|
||||
const canRedeem = computed(() =>
|
||||
Boolean(task.value && 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 (redeemLoading.value) {
|
||||
return '兑换任务正在执行中'
|
||||
}
|
||||
|
||||
if (!roleReady.value) {
|
||||
return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息'
|
||||
}
|
||||
|
||||
if (!roleConfirmed.value) {
|
||||
return '请先确认当前角色与大区无误,再开始兑换'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
const redeemButtonLabel = computed(() => '开始兑换')
|
||||
const screenshotEmptyTitle = computed(() =>
|
||||
task.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图',
|
||||
)
|
||||
const screenshotEmptyMessage = computed(() =>
|
||||
task.value?.status === 'redeemed'
|
||||
? '本次兑换可能未生成截图,或截图产物还未同步完成。'
|
||||
: '领取完成后,如本次生成了结果截图,这里会展示。'
|
||||
)
|
||||
|
||||
function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) {
|
||||
loginType.value = syncLoginTypeFromDetail(nextDetail)
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
task.value?.taskId || '',
|
||||
activityInfo.value?.nickname || '',
|
||||
activityInfo.value?.role?.roleId || '',
|
||||
activityInfo.value?.role?.roleName || '',
|
||||
activityInfo.value?.role?.area || '',
|
||||
activityInfo.value?.role?.partition || '',
|
||||
].join('|'),
|
||||
() => {
|
||||
roleConfirmed.value = Boolean(task.value?.status === 'role_confirmed' || task.value?.status === 'redeeming' || task.value?.status === 'redeemed')
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
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)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
restartPollingIfNeeded()
|
||||
} catch (error) {
|
||||
notifyTencentActionError(error, '领取详情加载失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSessionFlow(nextLoginType = loginType.value) {
|
||||
sessionLoading.value = true
|
||||
resetPolling()
|
||||
resetPollingWarning()
|
||||
|
||||
try {
|
||||
loginType.value = nextLoginType
|
||||
const response = await createClaimSession(token, nextLoginType)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '创建领取会话失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
startPolling()
|
||||
} catch (error) {
|
||||
notifyTencentActionError(error, '创建领取会话失败')
|
||||
} finally {
|
||||
sessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSessionSummary({ silent = false } = {}) {
|
||||
if (!hasSession.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
sessionLoading.value = true
|
||||
resetPollingWarning()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchClaimSessionSummary(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '领取会话状态刷新失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
applyLoginTypeFromDetail(response.data)
|
||||
|
||||
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 (!canConfirmRole.value) {
|
||||
return
|
||||
}
|
||||
|
||||
roleConfirmLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await confirmClaimRole(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '角色确认失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
roleConfirmed.value = true
|
||||
ElMessage.success(response.msg || '角色已确认')
|
||||
restartPollingIfNeeded(response.data)
|
||||
} catch (error) {
|
||||
notifyTencentActionError(error, '角色确认失败')
|
||||
await refreshSessionSummary({ silent: true })
|
||||
} finally {
|
||||
roleConfirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemNow() {
|
||||
if (!canRedeem.value) {
|
||||
return
|
||||
}
|
||||
|
||||
redeemLoading.value = true
|
||||
resetPolling()
|
||||
resetPollingWarning()
|
||||
|
||||
try {
|
||||
const response = await redeemClaim(token)
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '领取兑换失败')
|
||||
}
|
||||
|
||||
detail.value = mergeClaimDetailData(detail.value, response.data)
|
||||
ElMessage.success(response.msg || '兑换完成')
|
||||
restartPollingIfNeeded(response.data)
|
||||
} catch (error) {
|
||||
notifyTencentActionError(error, '领取兑换失败')
|
||||
await refreshSessionSummary({ silent: true })
|
||||
restartPollingIfNeeded()
|
||||
} finally {
|
||||
redeemLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function switchLoginType(nextLoginType: TencentLoginType) {
|
||||
if (nextLoginType === loginType.value) {
|
||||
return
|
||||
}
|
||||
|
||||
loginType.value = nextLoginType
|
||||
|
||||
if (hasSession.value) {
|
||||
await createSessionFlow(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`)
|
||||
|
||||
loadDetail()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
detailLoading,
|
||||
sessionLoading,
|
||||
redeemLoading,
|
||||
roleConfirmLoading,
|
||||
loginType,
|
||||
loginTypeLabel,
|
||||
loginTabs,
|
||||
detail,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
activityInfo,
|
||||
hasSession,
|
||||
qrImage,
|
||||
qrFigureStyle,
|
||||
qrPreviewWidth,
|
||||
statusLabel,
|
||||
session,
|
||||
sessionNotice,
|
||||
roleFacts,
|
||||
resultFacts,
|
||||
roleConfirmed,
|
||||
roleReady,
|
||||
canConfirmRole,
|
||||
canRedeem,
|
||||
redeemBlockedReason,
|
||||
redeemButtonLabel,
|
||||
initButtonLabel,
|
||||
scanInstruction,
|
||||
screenshotEmptyTitle,
|
||||
screenshotEmptyMessage,
|
||||
screenshotUrl,
|
||||
showScreenshot,
|
||||
createSessionFlow,
|
||||
refreshSessionSummary,
|
||||
switchLoginType,
|
||||
confirmRoleNow,
|
||||
redeemNow,
|
||||
handleQrImageLoad,
|
||||
}
|
||||
}
|
||||
|
||||
function syncLoginTypeFromDetail(detail: ClaimDetailData) {
|
||||
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
|
||||
return nextLoginType === 'wx' ? 'wx' : 'qq'
|
||||
}
|
||||
|
||||
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 (!current?.session) {
|
||||
return next
|
||||
}
|
||||
|
||||
if (!next.session) {
|
||||
return {
|
||||
...next,
|
||||
session: current.session,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
createTencentBrowserSession,
|
||||
refreshTencentBrowserSessionPage,
|
||||
removeTencentBrowserSession,
|
||||
} from '@/services/tencent/session'
|
||||
import { redeemTencentBrowserSession } from '@/services/tencent/redeem'
|
||||
import type { TencentBrowserSessionData, TencentLoginType } from '@/types/tencent/session'
|
||||
|
||||
import { notifyTencentActionError } from './tencent/session-errors'
|
||||
import { useTencentBrowserSessionPolling } from './tencent/useTencentBrowserSessionPolling'
|
||||
import { useTencentBrowserSessionPresentation } from './tencent/useTencentBrowserSessionPresentation'
|
||||
|
||||
const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
|
||||
|
||||
export function useTencentBrowserSessionPage() {
|
||||
const sessionLoading = ref(false)
|
||||
const redeemLoading = ref(false)
|
||||
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
|
||||
const session = ref<TencentBrowserSessionData | null>(null)
|
||||
const redeemCode = ref('')
|
||||
const maxAttempts = ref(6)
|
||||
const roleConfirmed = ref(false)
|
||||
|
||||
const activityInfo = computed(() => session.value?.activityInfo || null)
|
||||
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
|
||||
const hasSession = computed(() => Boolean(session.value?.sessionId))
|
||||
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
|
||||
|
||||
const loginTabs = [
|
||||
{ value: 'qq' as const, label: 'QQ账号登录' },
|
||||
{ value: 'wx' as const, label: '微信账号登录' },
|
||||
]
|
||||
|
||||
const {
|
||||
refreshSession,
|
||||
resetPolling,
|
||||
resetPollingWarning,
|
||||
restartPollingIfActive,
|
||||
sessionNotice,
|
||||
startPolling,
|
||||
} = useTencentBrowserSessionPolling({
|
||||
session,
|
||||
sessionLoading,
|
||||
notifyActionError: notifyTencentActionError,
|
||||
})
|
||||
|
||||
const {
|
||||
canRedeem,
|
||||
initButtonLabel,
|
||||
qrImage,
|
||||
redeemBlockedReason,
|
||||
redeemButtonLabel,
|
||||
resultFacts,
|
||||
roleFacts,
|
||||
scanInstruction,
|
||||
screenshotEmptyMessage,
|
||||
screenshotEmptyTitle,
|
||||
screenshotUrl,
|
||||
statusLabel,
|
||||
} = useTencentBrowserSessionPresentation({
|
||||
activityInfo,
|
||||
hasSession,
|
||||
loginTypeLabel,
|
||||
redeemLoading,
|
||||
roleConfirmed,
|
||||
roleReady,
|
||||
session,
|
||||
sessionLoading,
|
||||
sessionNotice,
|
||||
})
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
session.value?.sessionId || '',
|
||||
activityInfo.value?.nickname || '',
|
||||
activityInfo.value?.role?.roleId || '',
|
||||
activityInfo.value?.role?.roleName || '',
|
||||
activityInfo.value?.role?.area || '',
|
||||
activityInfo.value?.role?.partition || '',
|
||||
].join('|'),
|
||||
() => {
|
||||
roleConfirmed.value = false
|
||||
},
|
||||
)
|
||||
|
||||
async function createSessionFlow(nextLoginType = loginType.value) {
|
||||
resetPolling()
|
||||
resetPollingWarning()
|
||||
sessionLoading.value = true
|
||||
|
||||
try {
|
||||
loginType.value = nextLoginType
|
||||
await teardownSession()
|
||||
|
||||
const response = await createTencentBrowserSession({
|
||||
loginType: nextLoginType,
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '创建浏览器会话失败')
|
||||
}
|
||||
|
||||
session.value = response.data
|
||||
startPolling()
|
||||
} catch (error) {
|
||||
notifyTencentActionError(error, '创建浏览器会话失败')
|
||||
} finally {
|
||||
sessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadSessionPage() {
|
||||
if (!session.value?.sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
resetPolling()
|
||||
resetPollingWarning()
|
||||
sessionLoading.value = true
|
||||
|
||||
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) {
|
||||
notifyTencentActionError(error, '刷新后端页面失败')
|
||||
await refreshSession({ silent: true })
|
||||
restartPollingIfActive()
|
||||
} finally {
|
||||
sessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function switchLoginType(nextLoginType: TencentLoginType) {
|
||||
if (nextLoginType === loginType.value) {
|
||||
return
|
||||
}
|
||||
|
||||
loginType.value = nextLoginType
|
||||
|
||||
if (session.value?.sessionId) {
|
||||
await createSessionFlow(nextLoginType)
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemNow() {
|
||||
if (!session.value?.sessionId) {
|
||||
ElMessage.error('请先创建浏览器会话')
|
||||
return
|
||||
}
|
||||
|
||||
if (!redeemCode.value.trim()) {
|
||||
ElMessage.error('请输入兑换码')
|
||||
return
|
||||
}
|
||||
|
||||
redeemLoading.value = true
|
||||
resetPolling()
|
||||
resetPollingWarning()
|
||||
|
||||
try {
|
||||
const response = await redeemTencentBrowserSession(session.value.sessionId, {
|
||||
code: redeemCode.value.trim(),
|
||||
maxAttempts: maxAttempts.value,
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || '浏览器兑换失败')
|
||||
}
|
||||
|
||||
session.value = response.data
|
||||
ElMessage.success(response.msg || '兑换完成')
|
||||
} catch (error) {
|
||||
notifyTencentActionError(error, '浏览器兑换失败')
|
||||
await refreshSession({ silent: true })
|
||||
} finally {
|
||||
redeemLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function teardownSession() {
|
||||
if (!session.value?.sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = session.value.sessionId
|
||||
session.value = null
|
||||
|
||||
try {
|
||||
await removeTencentBrowserSession(sessionId)
|
||||
} catch {
|
||||
// ignore close failures on local teardown
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetPolling()
|
||||
void teardownSession()
|
||||
})
|
||||
|
||||
return {
|
||||
activityInfo,
|
||||
canRedeem,
|
||||
createSessionFlow,
|
||||
hasSession,
|
||||
initButtonLabel,
|
||||
loginTabs,
|
||||
loginType,
|
||||
loginTypeLabel,
|
||||
maxAttempts,
|
||||
qrImage,
|
||||
redeemBlockedReason,
|
||||
redeemButtonLabel,
|
||||
redeemCode,
|
||||
redeemLoading,
|
||||
redeemNow,
|
||||
reloadSessionPage,
|
||||
resultFacts,
|
||||
roleConfirmed,
|
||||
roleFacts,
|
||||
scanInstruction,
|
||||
screenshotEmptyMessage,
|
||||
screenshotEmptyTitle,
|
||||
screenshotUrl,
|
||||
session,
|
||||
sessionLoading,
|
||||
sessionNotice,
|
||||
statusLabel,
|
||||
switchLoginType,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user