Files
order_site/apps/frontend/src/composables/useAdminManualRedeemPage.ts
T

722 lines
21 KiB
TypeScript

import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showConfirm, showSuccess } from '@/lib/feedback'
import {
closeAdminManualRedeemTask,
confirmAdminManualRedeemRole,
createAdminManualRedeemSession,
createAdminManualRedeemTask,
fetchAdminManualRedeemDetail,
fetchAdminManualRedeemSessionSummary,
fetchAdminManualRedeemSkuSuggestions,
fetchAdminTaskScreenshot,
refreshAdminManualRedeemSession,
redeemAdminManualRedeem,
removeAdminManualRedeemSession,
} from '@/services/admin'
import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin'
import type { TencentLoginType } from '@/types/tencent/session'
import { notifyTencentActionError } from './tencent/session-errors'
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)
const sessionLoading = ref(false)
const roleConfirmLoading = ref(false)
const redeemLoading = ref(false)
const closeLoading = ref(false)
const loginType = ref<TencentLoginType>(DEFAULT_LOGIN_TYPE)
// ── Screenshot state (admin-only) ──────────────────────────────────
const screenshotBlob = ref<Blob | null>(null)
const screenshotUrl = ref('')
const screenshotLoading = ref(false)
// ── Derived state ──────────────────────────────────────────────────
const session = computed(() => detail.value?.session || null)
const task = computed(() => detail.value?.task || null)
const order = computed(() => detail.value?.order || null)
const orderItem = computed(() => detail.value?.orderItem || null)
const result = computed(() => detail.value?.result || null)
const manualRequest = computed(() => detail.value?.manualRequest || null)
const activityInfo = computed(() => session.value?.activityInfo || null)
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
const roleConfirmed = computed(() =>
Boolean(task.value && ['role_confirmed', 'redeeming', 'redeemed'].includes(task.value.status)),
)
const hasSession = computed(() => Boolean(session.value?.sessionId))
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
const currentTaskId = computed(() => Number(task.value?.taskId || route.query.taskId || 0) || 0)
const reviewReady = computed(
() => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed',
)
const resultReady = computed(
() => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed',
)
// ── 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, 'admin'),
)
const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`)
const scanInstruction = computed(
() => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`,
)
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 || '等待客户登录',
},
{
label: '当前角色',
value: activityInfo.value?.role?.roleName || '后端浏览器同步中',
accent: true,
},
{
label: '角色 ID',
value: activityInfo.value?.role?.roleId || '未识别',
},
{
label: '所在大区',
value: activityInfo.value?.role?.area || '未识别',
},
])
const canConfirmRole = computed(() =>
Boolean(
task.value &&
task.value.status === 'claimed' &&
hasSession.value &&
roleReady.value &&
!roleConfirmLoading.value,
),
)
const canRedeem = computed(() =>
Boolean(
task.value &&
['role_confirmed', 'redeeming'].includes(task.value.status) &&
!redeemLoading.value,
),
)
const redeemBlockedReason = computed(() =>
buildRedeemBlockedReason({
detail,
hasSession,
loginTypeLabel,
redeemLoading,
roleReady,
roleConfirmed,
sessionNotice,
context: 'admin',
}),
)
const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换'))
const screenshotEmptyTitle = computed(() =>
task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图',
)
const screenshotEmptyMessage = computed(() =>
task.value?.status === 'redeemed'
? '本次兑换可能未生成最终截图,或截图仍在同步中。'
: '客户登录并识别到角色后,这里会展示给客服复核的角色截图。',
)
const canCreateTask = computed(
() => Boolean(form.proofValue.trim() && form.skuCode.trim()) && !createLoading.value,
)
const hasActiveTask = computed(() => Boolean(task.value?.taskId))
// ── Watchers ───────────────────────────────────────────────────────
watch(qrImage, () => {
// QR image natural width reset handled by useSessionQrDisplay
})
watch(
() => currentTaskId.value,
(taskId, previousTaskId) => {
if (!taskId || taskId === previousTaskId) {
return
}
void loadDetail(taskId)
},
{ immediate: true },
)
onMounted(() => {
void loadSkuSuggestions()
})
onBeforeUnmount(() => {
resetPolling()
clearScreenshotPreview()
})
// ── Helpers ────────────────────────────────────────────────────────
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({
credentialType: 'tencent_code',
keyword: keyword.trim(),
})
suggestions.value = response.data.items
} catch (error) {
notifyTencentActionError(error, '读取人工兑换 SKU 建议失败')
}
}
async function loadDetail(taskId = currentTaskId.value) {
if (!taskId) {
detail.value = null
clearScreenshotPreview()
return false
}
detailLoading.value = true
try {
const response = await fetchAdminManualRedeemDetail(taskId)
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
await loadScreenshotPreview(response.data)
restartPollingIfNeeded()
return true
} catch (error) {
notifyTencentActionError(error, '读取人工兑换详情失败')
return false
} finally {
detailLoading.value = false
}
}
async function createTaskNow() {
if (!canCreateTask.value) {
return
}
createLoading.value = true
try {
const response = await createAdminManualRedeemTask({
proofValue: form.proofValue.trim(),
skuCode: form.skuCode.trim(),
skuName: resolveSkuName(form.skuCode),
remark: form.remark.trim(),
})
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
await loadScreenshotPreview(response.data)
await router.replace({
path: route.path,
query: {
taskId: String(response.data.task.taskId),
},
})
showSuccess(response.msg || '人工兑换任务已创建')
} catch (error) {
notifyTencentActionError(error, '创建人工兑换任务失败')
} finally {
createLoading.value = false
}
}
async function createSessionFlow(nextLoginType = loginType.value) {
if (!task.value?.taskId) {
return
}
sessionLoading.value = true
resetPolling()
resetPollingWarning()
try {
loginType.value = nextLoginType
const response = await createAdminManualRedeemSession(task.value.taskId, {
loginType: nextLoginType,
forceRecreate: hasSession.value,
})
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, nextLoginType)
await loadScreenshotPreview(response.data)
startPolling()
} catch (error) {
notifyTencentActionError(error, '创建人工兑换登录会话失败')
} finally {
sessionLoading.value = false
}
}
async function refreshSessionFlow() {
if (!task.value?.taskId) {
return false
}
sessionLoading.value = true
resetPolling()
resetPollingWarning()
try {
const response = await refreshAdminManualRedeemSession(task.value.taskId)
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
await loadScreenshotPreview(response.data)
restartPollingIfNeeded(response.data)
return true
} catch (error) {
notifyTencentActionError(error, '刷新人工兑换会话失败')
await refreshSessionSummary({ silent: true })
restartPollingIfNeeded()
return false
} finally {
sessionLoading.value = false
}
}
async function closeSessionFlow({ silent = false } = {}) {
if (!task.value?.taskId) {
return false
}
if (!silent) {
sessionLoading.value = true
}
resetPolling()
resetPollingWarning()
try {
const response = await removeAdminManualRedeemSession(task.value.taskId)
detail.value = response.data
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
await loadScreenshotPreview(response.data)
if (!silent) {
showSuccess(response.msg || '人工兑换会话已关闭')
}
return true
} catch (error) {
if (!silent) {
notifyTencentActionError(error, '关闭人工兑换会话失败')
}
return false
} finally {
if (!silent) {
sessionLoading.value = false
}
}
}
async function refreshSessionSummary({ silent = false } = {}) {
if (!task.value?.taskId) {
return false
}
if (!silent) {
sessionLoading.value = true
resetPollingWarning()
}
try {
const currentSession = detail.value?.session || null
const response = await fetchAdminManualRedeemSessionSummary(task.value.taskId)
detail.value = 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 = mergeSessionDetail(
detail.value,
fullResponse.data,
(currentSession, nextSession) => ({
review: nextSession.review ?? currentSession.review ?? null,
}),
)
loginType.value = syncLoginTypeFromDetail(fullResponse.data, loginType.value)
}
await loadScreenshotPreview(detail.value)
if (silent) {
resetPollingWarning()
}
return true
} catch (error) {
if (silent) {
handleSilentPollingError(error)
} else {
notifyTencentActionError(error, '刷新人工兑换会话状态失败')
}
return false
} finally {
if (!silent) {
sessionLoading.value = false
}
}
}
async function confirmRoleNow() {
if (!task.value?.taskId || !canConfirmRole.value) {
return
}
roleConfirmLoading.value = true
try {
const response = await confirmAdminManualRedeemRole(task.value.taskId)
detail.value = response.data
await loadScreenshotPreview(response.data)
showSuccess(response.msg || '角色已确认')
restartPollingIfNeeded(response.data)
} catch (error) {
notifyTencentActionError(error, '确认角色失败')
await refreshSessionSummary({ silent: true })
} finally {
roleConfirmLoading.value = false
}
}
async function redeemNow() {
if (!task.value?.taskId || !canRedeem.value) {
return
}
redeemLoading.value = true
resetPolling()
resetPollingWarning()
try {
const response = await redeemAdminManualRedeem(task.value.taskId)
detail.value = response.data
await loadScreenshotPreview(response.data)
showSuccess(response.msg || '兑换完成')
restartPollingIfNeeded(response.data)
} catch (error) {
notifyTencentActionError(error, '人工兑换失败')
await refreshSessionSummary({ silent: true })
restartPollingIfNeeded()
} finally {
redeemLoading.value = false
}
}
async function closeTaskNow() {
if (!task.value?.taskId) {
return
}
try {
await showConfirm(
`确认关闭任务 ${task.value.taskNo} 吗?关闭后会释放未消耗的资源,并结束当前人工兑换流程。`,
'关闭人工兑换',
)
} catch {
return
}
closeLoading.value = true
try {
const response = await closeAdminManualRedeemTask(task.value.taskId)
detail.value = response.data
await loadScreenshotPreview(response.data)
resetPolling()
showSuccess(response.msg || '人工兑换任务已关闭')
} catch (error) {
notifyTencentActionError(error, '关闭人工兑换任务失败')
} finally {
closeLoading.value = false
}
}
async function switchLoginType(nextLoginType: TencentLoginType) {
if (nextLoginType === loginType.value) {
return
}
if (hasSession.value) {
await createSessionFlow(nextLoginType)
return
}
loginType.value = nextLoginType
}
function resetCurrentTask() {
resetPolling()
detail.value = null
clearScreenshotPreview()
void router.replace({
path: route.path,
query: {},
})
}
async function copyQrImage() {
if (!qrImage.value) {
return false
}
try {
const response = await fetch(qrImage.value)
const blob = await response.blob()
await writeImageBlobToClipboard(blob, '复制二维码失败')
showSuccess('二维码已复制到剪贴板')
return true
} catch (error) {
notifyTencentActionError(error, '复制二维码失败')
return false
}
}
async function copyScreenshot() {
if (!screenshotBlob.value) {
return false
}
try {
await writeImageBlobToClipboard(screenshotBlob.value, '复制截图失败')
showSuccess('截图已复制到剪贴板')
return true
} catch (error) {
notifyTencentActionError(error, '复制截图失败')
return false
}
}
function downloadScreenshot() {
if (!screenshotBlob.value || !screenshotUrl.value) {
return false
}
const anchor = document.createElement('a')
anchor.href = screenshotUrl.value
anchor.download =
task.value?.status === 'redeemed'
? `manual-redeem-result-${task.value?.taskNo || 'task'}.png`
: `manual-redeem-review-${task.value?.taskNo || 'task'}.png`
anchor.click()
return true
}
return {
form,
suggestions,
detail,
detailLoading,
createLoading,
sessionLoading,
roleConfirmLoading,
redeemLoading,
closeLoading,
loginType,
loginTypeLabel,
loginTabs,
session,
task,
order,
orderItem,
manualRequest,
activityInfo,
roleReady,
roleConfirmed,
hasSession,
qrImage,
qrFigureStyle,
qrPreviewWidth,
statusLabel,
sessionNotice,
roleFacts: adminRoleFacts,
resultFacts,
canConfirmRole,
canRedeem,
redeemBlockedReason,
redeemButtonLabel,
initButtonLabel,
scanInstruction,
screenshotEmptyTitle,
screenshotEmptyMessage,
screenshotUrl,
screenshotLoading,
reviewReady,
resultReady,
canCreateTask,
hasActiveTask,
createTaskNow,
loadSkuSuggestions,
createSessionFlow,
refreshSessionFlow,
closeSessionFlow,
switchLoginType,
confirmRoleNow,
redeemNow,
closeTaskNow,
resetCurrentTask,
handleQrImageLoad,
copyQrImage,
copyScreenshot,
downloadScreenshot,
}
}