增加人工渠道
This commit is contained in:
@@ -4,6 +4,7 @@ import authRouter from './admin/auth.js'
|
|||||||
import auditLogsRouter from './admin/audit-logs.js'
|
import auditLogsRouter from './admin/audit-logs.js'
|
||||||
import dashboardRouter from './admin/dashboard.js'
|
import dashboardRouter from './admin/dashboard.js'
|
||||||
import inventoryRouter from './admin/inventory.js'
|
import inventoryRouter from './admin/inventory.js'
|
||||||
|
import manualRedeemRouter from './admin/manual-redeem.js'
|
||||||
import messageDeliveriesRouter from './admin/message-deliveries.js'
|
import messageDeliveriesRouter from './admin/message-deliveries.js'
|
||||||
import ordersRouter from './admin/orders.js'
|
import ordersRouter from './admin/orders.js'
|
||||||
import platformConfigRouter from './admin/platform-config.js'
|
import platformConfigRouter from './admin/platform-config.js'
|
||||||
@@ -23,6 +24,7 @@ router.use(auditLogsRouter)
|
|||||||
router.use(platformConfigRouter)
|
router.use(platformConfigRouter)
|
||||||
router.use(ordersRouter)
|
router.use(ordersRouter)
|
||||||
router.use(tasksRouter)
|
router.use(tasksRouter)
|
||||||
|
router.use(manualRedeemRouter)
|
||||||
router.use(inventoryRouter)
|
router.use(inventoryRouter)
|
||||||
router.use(messageDeliveriesRouter)
|
router.use(messageDeliveriesRouter)
|
||||||
router.use(webhookEventsRouter)
|
router.use(webhookEventsRouter)
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
import {
|
||||||
|
closeAdminManualRedeemSession,
|
||||||
|
closeAdminManualRedeemTask,
|
||||||
|
confirmAdminManualRedeemRole,
|
||||||
|
createAdminManualRedeemSession,
|
||||||
|
createAdminManualRedeemTask,
|
||||||
|
getAdminManualRedeemDetail,
|
||||||
|
getAdminManualRedeemSessionSummary,
|
||||||
|
reloadAdminManualRedeemSession,
|
||||||
|
redeemAdminManualRedeemTask,
|
||||||
|
} from '../../services/admin/admin-manual-redeem-service.js'
|
||||||
|
import { getAdminInventorySkuSuggestions } from '../../services/admin/admin-read-service.js'
|
||||||
|
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||||
|
|
||||||
|
/** @typedef {import('../../types/admin-route-inputs.js').AdminInventorySkuSuggestionRouteQuery} AdminInventorySkuSuggestionRouteQuery */
|
||||||
|
/** @typedef {import('../../types/admin-route-inputs.js').AdminManualRedeemCreateRouteBody} AdminManualRedeemCreateRouteBody */
|
||||||
|
/** @typedef {import('../../types/admin-route-inputs.js').AdminManualRedeemRouteParams} AdminManualRedeemRouteParams */
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support']))
|
||||||
|
|
||||||
|
router.get('/manual-redeem/sku-suggestions', createJsonHandler(
|
||||||
|
(req) => getAdminInventorySkuSuggestions(/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query)),
|
||||||
|
{
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取人工兑换 SKU 建议失败',
|
||||||
|
scope: '[admin/manual-redeem/sku-suggestions]',
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.post('/manual-redeem', createJsonHandler(
|
||||||
|
(req) => createAdminManualRedeemTask(
|
||||||
|
/** @type {AdminManualRedeemCreateRouteBody} */ (req.body),
|
||||||
|
req.adminSession || null,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: '人工兑换任务已创建',
|
||||||
|
errorMessage: '创建人工兑换任务失败',
|
||||||
|
scope: '[admin/manual-redeem]',
|
||||||
|
audit: (req, data) => {
|
||||||
|
const result = /** @type {{ task?: { taskId?: number, taskNo?: string }, manualRequest?: { proofValue?: string }, order?: { platformOrderId?: string }, orderItem?: { skuCode?: string } }} */ (data)
|
||||||
|
return {
|
||||||
|
action: 'manual_redeem_created',
|
||||||
|
targetType: 'task',
|
||||||
|
targetId: String(result.task?.taskId || ''),
|
||||||
|
data: {
|
||||||
|
taskId: result.task?.taskId,
|
||||||
|
taskNo: result.task?.taskNo,
|
||||||
|
proofValue: result.manualRequest?.proofValue || result.order?.platformOrderId || '',
|
||||||
|
skuCode: result.orderItem?.skuCode || '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.get('/manual-redeem/:taskId', createJsonHandler(
|
||||||
|
(req) => getAdminManualRedeemDetail((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
|
||||||
|
{
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取人工兑换详情失败',
|
||||||
|
scope: '[admin/manual-redeem/:taskId]',
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.post('/manual-redeem/:taskId/session', createJsonHandler(
|
||||||
|
(req) => createAdminManualRedeemSession(
|
||||||
|
(/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId,
|
||||||
|
/** @type {{ loginType?: string, forceRecreate?: boolean }} */ (req.body),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: '人工兑换登录会话已创建',
|
||||||
|
errorMessage: '创建人工兑换登录会话失败',
|
||||||
|
scope: '[admin/manual-redeem/:taskId/session]',
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.get('/manual-redeem/:taskId/session/summary', createJsonHandler(
|
||||||
|
(req) => getAdminManualRedeemSessionSummary((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
|
||||||
|
{
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取人工兑换会话摘要失败',
|
||||||
|
scope: '[admin/manual-redeem/:taskId/session/summary]',
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler(
|
||||||
|
(req) => reloadAdminManualRedeemSession((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
|
||||||
|
{
|
||||||
|
successMessage: '人工兑换会话已刷新',
|
||||||
|
errorMessage: '刷新人工兑换会话失败',
|
||||||
|
scope: '[admin/manual-redeem/:taskId/session/refresh]',
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.delete('/manual-redeem/:taskId/session', createJsonHandler(
|
||||||
|
(req) => closeAdminManualRedeemSession((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
|
||||||
|
{
|
||||||
|
successMessage: '人工兑换会话已关闭',
|
||||||
|
errorMessage: '关闭人工兑换会话失败',
|
||||||
|
scope: '[admin/manual-redeem/:taskId/session]',
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.post('/manual-redeem/:taskId/confirm-role', createJsonHandler(
|
||||||
|
(req) => confirmAdminManualRedeemRole(
|
||||||
|
(/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId,
|
||||||
|
req.adminSession || null,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: '角色已确认',
|
||||||
|
errorMessage: '确认人工兑换角色失败',
|
||||||
|
scope: '[admin/manual-redeem/:taskId/confirm-role]',
|
||||||
|
audit: (req, data) => {
|
||||||
|
const result = /** @type {{ task?: { taskId?: number, taskNo?: string, status?: string } }} */ (data)
|
||||||
|
return {
|
||||||
|
action: 'manual_redeem_role_confirmed',
|
||||||
|
targetType: 'task',
|
||||||
|
targetId: String(result.task?.taskId || ''),
|
||||||
|
data: {
|
||||||
|
taskId: result.task?.taskId,
|
||||||
|
taskNo: result.task?.taskNo,
|
||||||
|
status: result.task?.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.post('/manual-redeem/:taskId/redeem', createJsonHandler(
|
||||||
|
(req) => redeemAdminManualRedeemTask(
|
||||||
|
(/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId,
|
||||||
|
req.adminSession || null,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: '人工兑换任务已启动',
|
||||||
|
errorMessage: '执行人工兑换失败',
|
||||||
|
scope: '[admin/manual-redeem/:taskId/redeem]',
|
||||||
|
audit: (req, data) => {
|
||||||
|
const result = /** @type {{ task?: { taskId?: number, taskNo?: string, status?: string, deliveryStatus?: string } }} */ (data)
|
||||||
|
return {
|
||||||
|
action: 'manual_redeem_started',
|
||||||
|
targetType: 'task',
|
||||||
|
targetId: String(result.task?.taskId || ''),
|
||||||
|
data: {
|
||||||
|
taskId: result.task?.taskId,
|
||||||
|
taskNo: result.task?.taskNo,
|
||||||
|
status: result.task?.status,
|
||||||
|
deliveryStatus: result.task?.deliveryStatus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
router.post('/manual-redeem/:taskId/close', createJsonHandler(
|
||||||
|
(req) => closeAdminManualRedeemTask((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId),
|
||||||
|
{
|
||||||
|
successMessage: '人工兑换任务已关闭',
|
||||||
|
errorMessage: '关闭人工兑换任务失败',
|
||||||
|
scope: '[admin/manual-redeem/:taskId/close]',
|
||||||
|
audit: (req, data) => {
|
||||||
|
const result = /** @type {{ task?: { taskId?: number, taskNo?: string, status?: string } }} */ (data)
|
||||||
|
return {
|
||||||
|
action: 'manual_redeem_closed',
|
||||||
|
targetType: 'task',
|
||||||
|
targetId: String(result.task?.taskId || ''),
|
||||||
|
data: {
|
||||||
|
taskId: result.task?.taskId,
|
||||||
|
taskNo: result.task?.taskNo,
|
||||||
|
status: result.task?.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||||
|
import {
|
||||||
|
closeClaimSessionForAdminTask,
|
||||||
|
createClaimSessionForAdminTask,
|
||||||
|
getClaimDetailForAdminTask,
|
||||||
|
getClaimSessionSummaryForAdminTask,
|
||||||
|
reloadClaimSessionForAdminTask,
|
||||||
|
} from '../claim/claim-session-service.js'
|
||||||
|
import { reserveInventoryForTask } from '../order/inventory-service.js'
|
||||||
|
import { createHttpError } from '../../utils/http.js'
|
||||||
|
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
||||||
|
import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
|
||||||
|
import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js'
|
||||||
|
import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js'
|
||||||
|
import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js'
|
||||||
|
import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
||||||
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
import { randomId } from '../../utils/random.js'
|
||||||
|
import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js'
|
||||||
|
import { isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js'
|
||||||
|
|
||||||
|
const MANUAL_PROVIDER = 'manual'
|
||||||
|
const MANUAL_PLATFORM = 'manual_redeem'
|
||||||
|
const MANUAL_SOURCE_TYPE = 'admin_manual_redeem'
|
||||||
|
const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted'
|
||||||
|
|
||||||
|
/** @typedef {import('../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */
|
||||||
|
/** @typedef {import('../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */
|
||||||
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminManualRedeemCreateInput} AdminManualRedeemCreateInput */
|
||||||
|
|
||||||
|
/** @param {AdminManualRedeemCreateInput} [payload] */
|
||||||
|
/** @param {AdminViewerSessionInput | null} [session] */
|
||||||
|
export async function createAdminManualRedeemTask(
|
||||||
|
payload = /** @type {AdminManualRedeemCreateInput} */ ({}),
|
||||||
|
session = null,
|
||||||
|
) {
|
||||||
|
const proofValue = normalizeManualProofValue(payload.proofValue)
|
||||||
|
const skuCode = String(payload.skuCode || '').trim()
|
||||||
|
const skuName = String(payload.skuName || '').trim() || skuCode
|
||||||
|
const remark = String(payload.remark || '').trim()
|
||||||
|
|
||||||
|
if (!proofValue) {
|
||||||
|
throw createHttpError('请先输入唯一凭据', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'admin_manual_redeem_missing_proof',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skuCode) {
|
||||||
|
throw createHttpError('请先选择内部履约 SKU', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'admin_manual_redeem_missing_sku',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingOrder = await findOrderByPlatformOrderId({
|
||||||
|
provider: MANUAL_PROVIDER,
|
||||||
|
platform: MANUAL_PLATFORM,
|
||||||
|
shopId: '',
|
||||||
|
platformOrderId: proofValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingOrder) {
|
||||||
|
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'admin_manual_redeem_duplicate_proof',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY)
|
||||||
|
if (!profile) {
|
||||||
|
throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'admin_manual_redeem_profile_missing',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirements = await listFulfillmentProfileRequirements(profile.id)
|
||||||
|
const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null
|
||||||
|
const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code'
|
||||||
|
const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code'
|
||||||
|
const availableInventory = await findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType)
|
||||||
|
|
||||||
|
if (!availableInventory) {
|
||||||
|
throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'admin_manual_redeem_inventory_unavailable',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = nowIso()
|
||||||
|
const manualContext = {
|
||||||
|
sourceType: MANUAL_SOURCE_TYPE,
|
||||||
|
proofValue,
|
||||||
|
remark,
|
||||||
|
createdAt: now,
|
||||||
|
createdBy: session
|
||||||
|
? {
|
||||||
|
userId: Number(session.userId || 0) || 0,
|
||||||
|
username: String(session.username || '').trim(),
|
||||||
|
role: String(session.role || '').trim(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
let order
|
||||||
|
try {
|
||||||
|
order = await createOrder({
|
||||||
|
provider: MANUAL_PROVIDER,
|
||||||
|
platform: MANUAL_PLATFORM,
|
||||||
|
shopId: '',
|
||||||
|
shopName: '人工兑换',
|
||||||
|
platformOrderId: proofValue,
|
||||||
|
orderStatus: 'manual_created',
|
||||||
|
payStatus: 'paid',
|
||||||
|
buyerId: '',
|
||||||
|
buyerName: '',
|
||||||
|
receiverContact: '',
|
||||||
|
totalAmount: 0,
|
||||||
|
currency: 'CNY',
|
||||||
|
rawPayloadJson: JSON.stringify({
|
||||||
|
manualRedeem: manualContext,
|
||||||
|
}),
|
||||||
|
paidAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.code || '') === '23505') {
|
||||||
|
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'admin_manual_redeem_duplicate_proof',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw createHttpError('人工兑换订单创建失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'admin_manual_redeem_order_create_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderItems = await replaceOrderItems(order.id, [
|
||||||
|
{
|
||||||
|
skuCode,
|
||||||
|
skuName,
|
||||||
|
quantity: 1,
|
||||||
|
specJson: JSON.stringify({
|
||||||
|
title: skuName,
|
||||||
|
sourceType: MANUAL_SOURCE_TYPE,
|
||||||
|
}),
|
||||||
|
itemSnapshotJson: JSON.stringify({
|
||||||
|
manualRedeem: manualContext,
|
||||||
|
skuCode,
|
||||||
|
skuName,
|
||||||
|
}),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const orderItem = orderItems[0]
|
||||||
|
if (!orderItem) {
|
||||||
|
throw createHttpError('人工兑换订单商品创建失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'admin_manual_redeem_order_item_create_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdTask = await createTask({
|
||||||
|
orderId: order.id,
|
||||||
|
orderItemId: orderItem.id,
|
||||||
|
unitIndex: 1,
|
||||||
|
provider: MANUAL_PROVIDER,
|
||||||
|
platform: MANUAL_PLATFORM,
|
||||||
|
shopId: '',
|
||||||
|
shopName: '人工兑换',
|
||||||
|
platformOrderId: proofValue,
|
||||||
|
taskNo: randomId('MR'),
|
||||||
|
profileId: Number(profile.id),
|
||||||
|
executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY),
|
||||||
|
taskStatus: 'paid',
|
||||||
|
inventoryStatus: 'pending',
|
||||||
|
deliveryStatus: 'pending',
|
||||||
|
resultCode: '',
|
||||||
|
resultMessage: '',
|
||||||
|
claimToken: '',
|
||||||
|
claimExpiresAt: null,
|
||||||
|
automationMode: 'manual',
|
||||||
|
requiresClaim: true,
|
||||||
|
userActionStatus: 'pending_claim',
|
||||||
|
attemptCount: 0,
|
||||||
|
lastError: '',
|
||||||
|
contextJson: JSON.stringify({
|
||||||
|
profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY),
|
||||||
|
profileName: String(profile.name || ''),
|
||||||
|
skuCode,
|
||||||
|
skuName,
|
||||||
|
inventorySkuCode: skuCode,
|
||||||
|
primaryRequirement: primaryRequirement
|
||||||
|
? {
|
||||||
|
roleKey,
|
||||||
|
credentialType,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
manualRedeem: manualContext,
|
||||||
|
}),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!createdTask) {
|
||||||
|
throw createHttpError('人工兑换任务创建失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'admin_manual_redeem_task_create_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservedInventory = await reserveInventoryForTask({
|
||||||
|
skuCode,
|
||||||
|
taskId: createdTask.id,
|
||||||
|
credentialType,
|
||||||
|
roleKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!reservedInventory) {
|
||||||
|
await updateTask(createdTask.id, {
|
||||||
|
task_status: 'waiting_inventory',
|
||||||
|
inventory_status: 'pending',
|
||||||
|
last_error: '库存不足,无法为人工兑换任务预占库存',
|
||||||
|
updated_at: nowIso(),
|
||||||
|
})
|
||||||
|
|
||||||
|
throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'admin_manual_redeem_inventory_race_lost',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimToken = await createTaskClaimToken(createdTask.id)
|
||||||
|
await updateTask(createdTask.id, {
|
||||||
|
task_status: 'link_generated',
|
||||||
|
inventory_status: 'reserved',
|
||||||
|
claim_token: claimToken.token,
|
||||||
|
claim_expires_at: claimToken.expired_at,
|
||||||
|
user_action_status: 'pending_claim',
|
||||||
|
last_error: '',
|
||||||
|
updated_at: nowIso(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await createTaskEvent(createdTask.id, 'manual_redeem_created', {
|
||||||
|
sourceType: MANUAL_SOURCE_TYPE,
|
||||||
|
proofValue,
|
||||||
|
skuCode,
|
||||||
|
skuName,
|
||||||
|
inventoryItemId: Number(reservedInventory.id || 0) || null,
|
||||||
|
createdBy: manualContext.createdBy,
|
||||||
|
remark,
|
||||||
|
}, nowIso())
|
||||||
|
|
||||||
|
return getAdminManualRedeemDetail(createdTask.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {AdminEntityIdInput} taskId */
|
||||||
|
export async function getAdminManualRedeemDetail(taskId) {
|
||||||
|
const task = await getRequiredManualRedeemTask(taskId)
|
||||||
|
const detail = await getClaimDetailForAdminTask(task.id)
|
||||||
|
return decorateManualRedeemDetail(task, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {AdminEntityIdInput} taskId */
|
||||||
|
/** @param {{ loginType?: string, forceRecreate?: boolean }} [payload] */
|
||||||
|
export async function createAdminManualRedeemSession(taskId, payload = {}) {
|
||||||
|
const task = await getRequiredManualRedeemTask(taskId)
|
||||||
|
const detail = await createClaimSessionForAdminTask(task.id, payload)
|
||||||
|
return decorateManualRedeemDetail(task, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {AdminEntityIdInput} taskId */
|
||||||
|
export async function getAdminManualRedeemSessionSummary(taskId) {
|
||||||
|
const task = await getRequiredManualRedeemTask(taskId)
|
||||||
|
const detail = await getClaimSessionSummaryForAdminTask(task.id)
|
||||||
|
return decorateManualRedeemDetail(task, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {AdminEntityIdInput} taskId */
|
||||||
|
export async function reloadAdminManualRedeemSession(taskId) {
|
||||||
|
const task = await getRequiredManualRedeemTask(taskId)
|
||||||
|
const detail = await reloadClaimSessionForAdminTask(task.id)
|
||||||
|
return decorateManualRedeemDetail(task, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {AdminEntityIdInput} taskId */
|
||||||
|
export async function closeAdminManualRedeemSession(taskId) {
|
||||||
|
const task = await getRequiredManualRedeemTask(taskId)
|
||||||
|
const detail = await closeClaimSessionForAdminTask(task.id)
|
||||||
|
return decorateManualRedeemDetail(task, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {AdminEntityIdInput} taskId */
|
||||||
|
export async function closeAdminManualRedeemTask(taskId) {
|
||||||
|
const task = await getRequiredManualRedeemTask(taskId)
|
||||||
|
await closeAdminTask(task.id)
|
||||||
|
return getAdminManualRedeemDetail(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {AdminEntityIdInput} taskId */
|
||||||
|
/** @param {AdminViewerSessionInput | null} [session] */
|
||||||
|
export async function confirmAdminManualRedeemRole(taskId, session = null) {
|
||||||
|
const task = await getRequiredManualRedeemTask(taskId)
|
||||||
|
await confirmAdminTaskAssistedRole(task.id, session)
|
||||||
|
return getAdminManualRedeemDetail(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {AdminEntityIdInput} taskId */
|
||||||
|
/** @param {AdminViewerSessionInput | null} [session] */
|
||||||
|
export async function redeemAdminManualRedeemTask(taskId, session = null) {
|
||||||
|
const task = await getRequiredManualRedeemTask(taskId)
|
||||||
|
await redeemAdminTaskAssisted(task.id, session)
|
||||||
|
return getAdminManualRedeemDetail(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRequiredManualRedeemTask(taskId) {
|
||||||
|
const task = await getTaskById(Number(taskId))
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw createHttpError('人工兑换任务不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'admin_manual_redeem_task_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskContext = parseTaskContext(task)
|
||||||
|
const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim()
|
||||||
|
|
||||||
|
if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) {
|
||||||
|
throw createHttpError('当前任务不是人工兑换任务', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'admin_manual_redeem_task_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateManualRedeemDetail(task, detail) {
|
||||||
|
const taskContext = parseTaskContext(task)
|
||||||
|
const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object'
|
||||||
|
? taskContext.manualRedeem
|
||||||
|
: {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
claimUrl: '',
|
||||||
|
manualRequest: {
|
||||||
|
sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE,
|
||||||
|
proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(),
|
||||||
|
remark: String(manualRedeem.remark || '').trim(),
|
||||||
|
},
|
||||||
|
result: detail.result
|
||||||
|
? {
|
||||||
|
...detail.result,
|
||||||
|
screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManualProofValue(value) {
|
||||||
|
return String(value || '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
@@ -44,6 +44,20 @@ export async function getClaimDetail(token, { includeQrImage = true } = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) {
|
||||||
|
const context = await getClaimContextByTaskId(taskId)
|
||||||
|
const { task, session } = await loadTaskSession(context.task, { includeQrImage })
|
||||||
|
const syncedTask = session ? await syncTaskWithSession(task, session) : task
|
||||||
|
|
||||||
|
return buildClaimDetailPayload({
|
||||||
|
claimToken: context.claimToken,
|
||||||
|
task: syncedTask,
|
||||||
|
order: context.order,
|
||||||
|
orderItem: context.orderItem,
|
||||||
|
session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function createClaimSession(token, payload = {}) {
|
export async function createClaimSession(token, payload = {}) {
|
||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
assertTaskCanProceed(context.task)
|
assertTaskCanProceed(context.task)
|
||||||
@@ -106,6 +120,68 @@ export async function createClaimSession(token, payload = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createClaimSessionForAdminTask(taskId, payload = {}) {
|
||||||
|
const context = await getClaimContextByTaskId(taskId)
|
||||||
|
assertTaskCanProceed(context.task)
|
||||||
|
const requestedLoginType = normalizeClaimLoginType(payload.loginType)
|
||||||
|
const forceRecreate = Boolean(payload.forceRecreate)
|
||||||
|
let task = context.task
|
||||||
|
|
||||||
|
if (task.browser_session_id) {
|
||||||
|
const existing = await loadTaskSession(task, { includeQrImage: true })
|
||||||
|
task = existing.task
|
||||||
|
|
||||||
|
if (existing.session) {
|
||||||
|
const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type)
|
||||||
|
|
||||||
|
if (!forceRecreate && existingLoginType === requestedLoginType) {
|
||||||
|
const syncedTask = await syncTaskWithSession(task, existing.session)
|
||||||
|
|
||||||
|
return buildClaimDetailPayload({
|
||||||
|
claimToken: context.claimToken,
|
||||||
|
task: syncedTask,
|
||||||
|
order: context.order,
|
||||||
|
orderItem: context.orderItem,
|
||||||
|
session: existing.session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await closeTencentBrowserSession(existing.session.sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRecoverableSessionError(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task = await clearTaskSession(task, {
|
||||||
|
lastError: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await createTencentBrowserSession({
|
||||||
|
loginType: requestedLoginType,
|
||||||
|
})
|
||||||
|
const updatedTask = await updateTask(task.id, {
|
||||||
|
task_status: 'claimed',
|
||||||
|
browser_session_id: session.sessionId,
|
||||||
|
login_type: session.loginType,
|
||||||
|
user_action_status: 'claimed',
|
||||||
|
claimed_at: task.claimed_at || nowIso(),
|
||||||
|
updated_at: nowIso(),
|
||||||
|
last_error: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return buildClaimDetailPayload({
|
||||||
|
claimToken: context.claimToken,
|
||||||
|
task: updatedTask,
|
||||||
|
order: context.order,
|
||||||
|
orderItem: context.orderItem,
|
||||||
|
session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function getClaimSessionSummary(token) {
|
export async function getClaimSessionSummary(token) {
|
||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
const { task, session } = await loadTaskSession(context.task, {
|
const { task, session } = await loadTaskSession(context.task, {
|
||||||
@@ -133,6 +209,33 @@ export async function getClaimSessionSummary(token) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getClaimSessionSummaryForAdminTask(taskId) {
|
||||||
|
const context = await getClaimContextByTaskId(taskId)
|
||||||
|
const { task, session } = await loadTaskSession(context.task, {
|
||||||
|
includeQrImage: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return buildClaimDetailPayload({
|
||||||
|
claimToken: context.claimToken,
|
||||||
|
task,
|
||||||
|
order: context.order,
|
||||||
|
orderItem: context.orderItem,
|
||||||
|
session: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncedTask = await syncTaskWithSession(task, session)
|
||||||
|
|
||||||
|
return buildClaimDetailPayload({
|
||||||
|
claimToken: context.claimToken,
|
||||||
|
task: syncedTask,
|
||||||
|
order: context.order,
|
||||||
|
orderItem: context.orderItem,
|
||||||
|
session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function reloadClaimSession(token) {
|
export async function reloadClaimSession(token) {
|
||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
assertTaskCanProceed(context.task)
|
assertTaskCanProceed(context.task)
|
||||||
@@ -163,6 +266,36 @@ export async function reloadClaimSession(token) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reloadClaimSessionForAdminTask(taskId) {
|
||||||
|
const context = await getClaimContextByTaskId(taskId)
|
||||||
|
assertTaskCanProceed(context.task)
|
||||||
|
|
||||||
|
const active = await loadTaskSession(context.task, {
|
||||||
|
includeQrImage: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!active.session) {
|
||||||
|
return buildClaimDetailPayload({
|
||||||
|
claimToken: context.claimToken,
|
||||||
|
task: active.task,
|
||||||
|
order: context.order,
|
||||||
|
orderItem: context.orderItem,
|
||||||
|
session: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await reloadTencentBrowserSession(active.session.sessionId)
|
||||||
|
const syncedTask = await syncTaskWithSession(active.task, session)
|
||||||
|
|
||||||
|
return buildClaimDetailPayload({
|
||||||
|
claimToken: context.claimToken,
|
||||||
|
task: syncedTask,
|
||||||
|
order: context.order,
|
||||||
|
orderItem: context.orderItem,
|
||||||
|
session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function closeClaimSession(token) {
|
export async function closeClaimSession(token) {
|
||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
assertTaskCanProceed(context.task)
|
assertTaskCanProceed(context.task)
|
||||||
@@ -199,6 +332,42 @@ export async function closeClaimSession(token) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function closeClaimSessionForAdminTask(taskId) {
|
||||||
|
const context = await getClaimContextByTaskId(taskId)
|
||||||
|
assertTaskCanProceed(context.task)
|
||||||
|
let task = context.task
|
||||||
|
|
||||||
|
if (!task.browser_session_id) {
|
||||||
|
return buildClaimDetailPayload({
|
||||||
|
claimToken: context.claimToken,
|
||||||
|
task,
|
||||||
|
order: context.order,
|
||||||
|
orderItem: context.orderItem,
|
||||||
|
session: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await closeTencentBrowserSession(task.browser_session_id)
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRecoverableSessionError(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task = await clearTaskSession(task, {
|
||||||
|
lastError: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return buildClaimDetailPayload({
|
||||||
|
claimToken: context.claimToken,
|
||||||
|
task,
|
||||||
|
order: context.order,
|
||||||
|
orderItem: context.orderItem,
|
||||||
|
session: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function confirmClaimRole(token) {
|
export async function confirmClaimRole(token) {
|
||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
assertPublicClaimActionAllowed(context.task, 'confirm')
|
assertPublicClaimActionAllowed(context.task, 'confirm')
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export {}
|
|||||||
* @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody
|
* @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./admin-write-inputs.js').AdminManualRedeemCreateInput} AdminManualRedeemCreateRouteBody
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ inventoryItemId?: string }} AdminInventoryRouteParams
|
* @typedef {{ inventoryItemId?: string }} AdminInventoryRouteParams
|
||||||
*/
|
*/
|
||||||
@@ -70,6 +74,10 @@ export {}
|
|||||||
* @typedef {{ taskId?: string, bindingId?: string }} AdminTaskRouteParams
|
* @typedef {{ taskId?: string, bindingId?: string }} AdminTaskRouteParams
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ taskId?: string }} AdminManualRedeemRouteParams
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ eventId?: string }} AdminWebhookEventRouteParams
|
* @typedef {{ eventId?: string }} AdminWebhookEventRouteParams
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -107,3 +107,12 @@ export {}
|
|||||||
* deliveredCredential?: string
|
* deliveredCredential?: string
|
||||||
* }} AdminTaskManualDispatchInput
|
* }} AdminTaskManualDispatchInput
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* proofValue?: string
|
||||||
|
* skuCode?: string
|
||||||
|
* skuName?: string
|
||||||
|
* remark?: string
|
||||||
|
* }} AdminManualRedeemCreateInput
|
||||||
|
*/
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ export {}
|
|||||||
* role_name: string
|
* role_name: string
|
||||||
* role_id: string
|
* role_id: string
|
||||||
* claim_token: string
|
* claim_token: string
|
||||||
|
* claim_expires_at?: string | null
|
||||||
* primary_claim_token: string
|
* primary_claim_token: string
|
||||||
* primary_claim_token_id: number | null
|
* primary_claim_token_id: number | null
|
||||||
|
* primary_claim_expires_at?: string | null
|
||||||
* primary_inventory_item_id: number | null
|
* primary_inventory_item_id: number | null
|
||||||
* inventory_display_value: string
|
* inventory_display_value: string
|
||||||
* primary_inventory_display_value: string
|
* primary_inventory_display_value: string
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const props = defineProps<{
|
|||||||
loginTabs: LoginTab[]
|
loginTabs: LoginTab[]
|
||||||
loginType: TencentLoginType
|
loginType: TencentLoginType
|
||||||
loginTypeLabel: string
|
loginTypeLabel: string
|
||||||
|
qrCopyButtonLabel?: string
|
||||||
|
qrCopyEnabled?: boolean
|
||||||
qrFigureStyle: { width: string; maxWidth: string }
|
qrFigureStyle: { width: string; maxWidth: string }
|
||||||
qrImage: string
|
qrImage: string
|
||||||
scanInstruction: string
|
scanInstruction: string
|
||||||
@@ -24,6 +26,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
closeSession: []
|
closeSession: []
|
||||||
|
copyQr: []
|
||||||
createSession: [loginType: TencentLoginType]
|
createSession: [loginType: TencentLoginType]
|
||||||
openQrPreview: []
|
openQrPreview: []
|
||||||
qrImageLoad: [event: Event]
|
qrImageLoad: [event: Event]
|
||||||
@@ -53,6 +56,7 @@ const emit = defineEmits<{
|
|||||||
:style="props.qrFigureStyle"
|
:style="props.qrFigureStyle"
|
||||||
type="button"
|
type="button"
|
||||||
@click="emit('openQrPreview')"
|
@click="emit('openQrPreview')"
|
||||||
|
@dblclick.prevent.stop="emit('copyQr')"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="props.qrImage"
|
:src="props.qrImage"
|
||||||
@@ -91,6 +95,12 @@ const emit = defineEmits<{
|
|||||||
<p class="scan-hint">
|
<p class="scan-hint">
|
||||||
{{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}
|
{{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="props.qrCopyEnabled && props.qrImage && props.sessionStatus === 'waiting_scan'"
|
||||||
|
class="scan-tip"
|
||||||
|
>
|
||||||
|
双击二维码可直接复制到剪贴板,也可以使用下方按钮复制。
|
||||||
|
</p>
|
||||||
<p class="scan-note">
|
<p class="scan-note">
|
||||||
{{
|
{{
|
||||||
props.sessionNotice ||
|
props.sessionNotice ||
|
||||||
@@ -118,6 +128,17 @@ const emit = defineEmits<{
|
|||||||
{{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }}
|
{{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
v-if="props.qrCopyEnabled"
|
||||||
|
:disabled="!props.qrImage || props.sessionStatus !== 'waiting_scan'"
|
||||||
|
:loading="props.sessionLoading"
|
||||||
|
class="copy-action"
|
||||||
|
size="large"
|
||||||
|
@click="emit('copyQr')"
|
||||||
|
>
|
||||||
|
{{ props.qrCopyButtonLabel || '复制二维码' }}
|
||||||
|
</el-button>
|
||||||
|
|
||||||
<div class="secondary-actions">
|
<div class="secondary-actions">
|
||||||
<el-button
|
<el-button
|
||||||
:disabled="!props.hasSession"
|
:disabled="!props.hasSession"
|
||||||
@@ -308,6 +329,13 @@ const emit = defineEmits<{
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scan-tip {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #2859b8;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-footer {
|
.auth-footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
@@ -374,6 +402,7 @@ const emit = defineEmits<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
.primary-action,
|
.primary-action,
|
||||||
|
.copy-action,
|
||||||
.secondary-action,
|
.secondary-action,
|
||||||
.tertiary-action {
|
.tertiary-action {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -389,6 +418,7 @@ const emit = defineEmits<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-row :deep(.secondary-action.el-button),
|
.action-row :deep(.secondary-action.el-button),
|
||||||
|
.action-row :deep(.copy-action.el-button),
|
||||||
.action-row :deep(.tertiary-action.el-button) {
|
.action-row :deep(.tertiary-action.el-button) {
|
||||||
background: rgba(248, 251, 255, 0.98);
|
background: rgba(248, 251, 255, 0.98);
|
||||||
border: 1px solid #d9e4f2;
|
border: 1px solid #d9e4f2;
|
||||||
@@ -396,6 +426,8 @@ const emit = defineEmits<{
|
|||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-row :deep(.copy-action.el-button:hover),
|
||||||
|
.action-row :deep(.copy-action.el-button:focus-visible),
|
||||||
.action-row :deep(.secondary-action.el-button:hover),
|
.action-row :deep(.secondary-action.el-button:hover),
|
||||||
.action-row :deep(.secondary-action.el-button:focus-visible) {
|
.action-row :deep(.secondary-action.el-button:focus-visible) {
|
||||||
border-color: #9dc0f6;
|
border-color: #9dc0f6;
|
||||||
@@ -410,6 +442,7 @@ const emit = defineEmits<{
|
|||||||
background: #fff6f6;
|
background: #fff6f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-row :deep(.copy-action.el-button.is-disabled),
|
||||||
.action-row :deep(.secondary-action.el-button.is-disabled),
|
.action-row :deep(.secondary-action.el-button.is-disabled),
|
||||||
.action-row :deep(.tertiary-action.el-button.is-disabled) {
|
.action-row :deep(.tertiary-action.el-button.is-disabled) {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
|
|||||||
@@ -0,0 +1,836 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { showConfirm, showSuccess } from '@/lib/feedback'
|
||||||
|
import {
|
||||||
|
closeAdminManualRedeemTask,
|
||||||
|
confirmAdminManualRedeemRole,
|
||||||
|
createAdminManualRedeemSession,
|
||||||
|
createAdminManualRedeemTask,
|
||||||
|
fetchAdminManualRedeemDetail,
|
||||||
|
fetchAdminManualRedeemSessionSummary,
|
||||||
|
fetchAdminManualRedeemSkuSuggestions,
|
||||||
|
fetchAdminTaskScreenshot,
|
||||||
|
refreshAdminManualRedeemSession,
|
||||||
|
redeemAdminManualRedeem,
|
||||||
|
removeAdminManualRedeemSession,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin'
|
||||||
|
import type { TencentLoginType } from '@/types/tencent/session'
|
||||||
|
|
||||||
|
import { notifyTencentActionError } from './tencent/session-errors'
|
||||||
|
|
||||||
|
const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq'
|
||||||
|
const POLL_INTERVAL_MS = 2500
|
||||||
|
const POLL_FAILURE_LIMIT = 3
|
||||||
|
const ACTIVE_TASK_STATUSES = new Set(['claimed', 'role_confirmed', 'redeeming'])
|
||||||
|
|
||||||
|
export function useAdminManualRedeemPage() {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
proofValue: '',
|
||||||
|
skuCode: '',
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
const suggestions = ref<AdminInventorySkuSuggestion[]>([])
|
||||||
|
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 pollWarningMessage = ref('')
|
||||||
|
const qrImageNaturalWidth = ref(0)
|
||||||
|
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
|
||||||
|
|
||||||
|
const session = computed(() => detail.value?.session || null)
|
||||||
|
const task = computed(() => detail.value?.task || null)
|
||||||
|
const order = computed(() => detail.value?.order || null)
|
||||||
|
const orderItem = computed(() => detail.value?.orderItem || null)
|
||||||
|
const result = computed(() => detail.value?.result || null)
|
||||||
|
const manualRequest = computed(() => detail.value?.manualRequest || null)
|
||||||
|
const activityInfo = computed(() => session.value?.activityInfo || null)
|
||||||
|
const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready))
|
||||||
|
const roleConfirmed = computed(() =>
|
||||||
|
Boolean(task.value && ['role_confirmed', 'redeeming', 'redeemed'].includes(task.value.status)),
|
||||||
|
)
|
||||||
|
const hasSession = computed(() => Boolean(session.value?.sessionId))
|
||||||
|
const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ'))
|
||||||
|
const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '')
|
||||||
|
const qrImage = computed(() =>
|
||||||
|
session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '',
|
||||||
|
)
|
||||||
|
const currentTaskId = computed(() => Number(task.value?.taskId || route.query.taskId || 0) || 0)
|
||||||
|
const reviewReady = computed(() => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed')
|
||||||
|
const resultReady = computed(() => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed')
|
||||||
|
const loginTabs = [
|
||||||
|
{ value: 'qq' as const, label: 'QQ账号登录' },
|
||||||
|
{ value: 'wx' as const, label: '微信账号登录' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusLabel = computed(() => resolveTaskStatusLabel(task.value?.status, session.value?.status))
|
||||||
|
const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`)
|
||||||
|
const scanInstruction = computed(() => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`)
|
||||||
|
const roleFacts = computed(() => [
|
||||||
|
{
|
||||||
|
label: '登录昵称',
|
||||||
|
value: activityInfo.value?.nickname || '等待客户登录',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '当前角色',
|
||||||
|
value: activityInfo.value?.role?.roleName || '后端浏览器同步中',
|
||||||
|
accent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '角色 ID',
|
||||||
|
value: activityInfo.value?.role?.roleId || '未识别',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '所在大区',
|
||||||
|
value: activityInfo.value?.role?.area || '未识别',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const resultFacts = computed(() => [
|
||||||
|
{
|
||||||
|
label: '唯一凭据',
|
||||||
|
value: manualRequest.value?.proofValue || order.value?.platformOrderId || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '商品',
|
||||||
|
value: orderItem.value?.skuName || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '业务返回码',
|
||||||
|
value: result.value?.resultCode || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '业务消息',
|
||||||
|
value: result.value?.resultMessage || '尚未兑换',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const canConfirmRole = computed(() =>
|
||||||
|
Boolean(
|
||||||
|
task.value
|
||||||
|
&& task.value.status === 'claimed'
|
||||||
|
&& hasSession.value
|
||||||
|
&& roleReady.value
|
||||||
|
&& !roleConfirmLoading.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const canRedeem = computed(() =>
|
||||||
|
Boolean(
|
||||||
|
task.value
|
||||||
|
&& ['role_confirmed', 'redeeming'].includes(task.value.status)
|
||||||
|
&& !redeemLoading.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const redeemBlockedReason = computed(() => {
|
||||||
|
if (!task.value) {
|
||||||
|
return '请先创建人工兑换任务并预占库存'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSession.value) {
|
||||||
|
return `请先生成${loginTypeLabel.value}二维码并等待客户登录`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redeemLoading.value) {
|
||||||
|
return '兑换任务正在执行中'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roleReady.value) {
|
||||||
|
return sessionNotice.value || '客户登录后,系统会自动同步角色和大区信息'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roleConfirmed.value) {
|
||||||
|
return '请先让客户确认角色截图,再点击确认当前角色'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换'))
|
||||||
|
const screenshotEmptyTitle = computed(() => (task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图'))
|
||||||
|
const screenshotEmptyMessage = computed(() =>
|
||||||
|
task.value?.status === 'redeemed'
|
||||||
|
? '本次兑换可能未生成最终截图,或截图仍在同步中。'
|
||||||
|
: '客户登录并识别到角色后,这里会展示给客服复核的角色截图。'
|
||||||
|
)
|
||||||
|
const canCreateTask = computed(() => Boolean(form.proofValue.trim() && form.skuCode.trim()) && !createLoading.value)
|
||||||
|
const hasActiveTask = computed(() => Boolean(task.value?.taskId))
|
||||||
|
|
||||||
|
watch(qrImage, () => {
|
||||||
|
qrImageNaturalWidth.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => currentTaskId.value,
|
||||||
|
(taskId, previousTaskId) => {
|
||||||
|
if (!taskId || taskId === previousTaskId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadDetail(taskId)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadSkuSuggestions()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resetPolling()
|
||||||
|
clearScreenshotPreview()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadSkuSuggestions(keyword = '') {
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminManualRedeemSkuSuggestions({
|
||||||
|
credentialType: 'tencent_code',
|
||||||
|
keyword: keyword.trim(),
|
||||||
|
})
|
||||||
|
suggestions.value = response.data.items
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '读取人工兑换 SKU 建议失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(taskId = currentTaskId.value) {
|
||||||
|
if (!taskId) {
|
||||||
|
detail.value = null
|
||||||
|
clearScreenshotPreview()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminManualRedeemDetail(taskId)
|
||||||
|
detail.value = response.data
|
||||||
|
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
|
||||||
|
await loadScreenshotPreview(response.data)
|
||||||
|
restartPollingIfNeeded()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '读取人工兑换详情失败')
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTaskNow() {
|
||||||
|
if (!canCreateTask.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await createAdminManualRedeemTask({
|
||||||
|
proofValue: form.proofValue.trim(),
|
||||||
|
skuCode: form.skuCode.trim(),
|
||||||
|
skuName: resolveSkuName(form.skuCode),
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
detail.value = response.data
|
||||||
|
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
|
||||||
|
await loadScreenshotPreview(response.data)
|
||||||
|
await router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: {
|
||||||
|
taskId: String(response.data.task.taskId),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
showSuccess(response.msg || '人工兑换任务已创建')
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '创建人工兑换任务失败')
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSessionFlow(nextLoginType = loginType.value) {
|
||||||
|
if (!task.value?.taskId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionLoading.value = true
|
||||||
|
resetPolling()
|
||||||
|
resetPollingWarning()
|
||||||
|
|
||||||
|
try {
|
||||||
|
loginType.value = nextLoginType
|
||||||
|
const response = await createAdminManualRedeemSession(task.value.taskId, {
|
||||||
|
loginType: nextLoginType,
|
||||||
|
forceRecreate: hasSession.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
detail.value = response.data
|
||||||
|
loginType.value = syncLoginTypeFromDetail(response.data, nextLoginType)
|
||||||
|
await loadScreenshotPreview(response.data)
|
||||||
|
startPolling()
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '创建人工兑换登录会话失败')
|
||||||
|
} finally {
|
||||||
|
sessionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSessionFlow() {
|
||||||
|
if (!task.value?.taskId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionLoading.value = true
|
||||||
|
resetPolling()
|
||||||
|
resetPollingWarning()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await refreshAdminManualRedeemSession(task.value.taskId)
|
||||||
|
detail.value = response.data
|
||||||
|
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
|
||||||
|
await loadScreenshotPreview(response.data)
|
||||||
|
restartPollingIfNeeded(response.data)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '刷新人工兑换会话失败')
|
||||||
|
await refreshSessionSummary({ silent: true })
|
||||||
|
restartPollingIfNeeded()
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
sessionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSessionFlow({ silent = false } = {}) {
|
||||||
|
if (!task.value?.taskId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
sessionLoading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPolling()
|
||||||
|
resetPollingWarning()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await removeAdminManualRedeemSession(task.value.taskId)
|
||||||
|
detail.value = response.data
|
||||||
|
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
|
||||||
|
await loadScreenshotPreview(response.data)
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
showSuccess(response.msg || '人工兑换会话已关闭')
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) {
|
||||||
|
notifyTencentActionError(error, '关闭人工兑换会话失败')
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
sessionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSessionSummary({ silent = false } = {}) {
|
||||||
|
if (!task.value?.taskId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
sessionLoading.value = true
|
||||||
|
resetPollingWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentSession = detail.value?.session || null
|
||||||
|
const response = await fetchAdminManualRedeemSessionSummary(task.value.taskId)
|
||||||
|
detail.value = mergeDetail(detail.value, response.data)
|
||||||
|
loginType.value = syncLoginTypeFromDetail(response.data, loginType.value)
|
||||||
|
|
||||||
|
if (shouldRefreshQrImage(currentSession, response.data.session)) {
|
||||||
|
const fullResponse = await fetchAdminManualRedeemDetail(task.value.taskId)
|
||||||
|
detail.value = mergeDetail(detail.value, fullResponse.data)
|
||||||
|
loginType.value = syncLoginTypeFromDetail(fullResponse.data, loginType.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadScreenshotPreview(detail.value)
|
||||||
|
|
||||||
|
if (silent) {
|
||||||
|
resetPollingWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (silent) {
|
||||||
|
handleSilentPollingError(error)
|
||||||
|
} else {
|
||||||
|
notifyTencentActionError(error, '刷新人工兑换会话状态失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
sessionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRoleNow() {
|
||||||
|
if (!task.value?.taskId || !canConfirmRole.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roleConfirmLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await confirmAdminManualRedeemRole(task.value.taskId)
|
||||||
|
detail.value = response.data
|
||||||
|
await loadScreenshotPreview(response.data)
|
||||||
|
showSuccess(response.msg || '角色已确认')
|
||||||
|
restartPollingIfNeeded(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '确认角色失败')
|
||||||
|
await refreshSessionSummary({ silent: true })
|
||||||
|
} finally {
|
||||||
|
roleConfirmLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redeemNow() {
|
||||||
|
if (!task.value?.taskId || !canRedeem.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redeemLoading.value = true
|
||||||
|
resetPolling()
|
||||||
|
resetPollingWarning()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await redeemAdminManualRedeem(task.value.taskId)
|
||||||
|
detail.value = response.data
|
||||||
|
await loadScreenshotPreview(response.data)
|
||||||
|
showSuccess(response.msg || '兑换完成')
|
||||||
|
restartPollingIfNeeded(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '人工兑换失败')
|
||||||
|
await refreshSessionSummary({ silent: true })
|
||||||
|
restartPollingIfNeeded()
|
||||||
|
} finally {
|
||||||
|
redeemLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeTaskNow() {
|
||||||
|
if (!task.value?.taskId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await showConfirm(
|
||||||
|
`确认关闭任务 ${task.value.taskNo} 吗?关闭后会释放未消耗的资源,并结束当前人工兑换流程。`,
|
||||||
|
'关闭人工兑换',
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await closeAdminManualRedeemTask(task.value.taskId)
|
||||||
|
detail.value = response.data
|
||||||
|
await loadScreenshotPreview(response.data)
|
||||||
|
resetPolling()
|
||||||
|
showSuccess(response.msg || '人工兑换任务已关闭')
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '关闭人工兑换任务失败')
|
||||||
|
} finally {
|
||||||
|
closeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchLoginType(nextLoginType: TencentLoginType) {
|
||||||
|
if (nextLoginType === loginType.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSession.value) {
|
||||||
|
await createSessionFlow(nextLoginType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loginType.value = nextLoginType
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCurrentTask() {
|
||||||
|
resetPolling()
|
||||||
|
detail.value = null
|
||||||
|
clearScreenshotPreview()
|
||||||
|
void router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQrImageLoad(event: Event) {
|
||||||
|
const target = event.target
|
||||||
|
|
||||||
|
if (!(target instanceof HTMLImageElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
qrImageNaturalWidth.value = target.naturalWidth || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyQrImage() {
|
||||||
|
if (!qrImage.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(qrImage.value)
|
||||||
|
const blob = await response.blob()
|
||||||
|
await writeImageBlobToClipboard(blob, '复制二维码失败')
|
||||||
|
showSuccess('二维码已复制到剪贴板')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '复制二维码失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyScreenshot() {
|
||||||
|
if (!screenshotBlob.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeImageBlobToClipboard(screenshotBlob.value, '复制截图失败')
|
||||||
|
showSuccess('截图已复制到剪贴板')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
notifyTencentActionError(error, '复制截图失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadScreenshot() {
|
||||||
|
if (!screenshotBlob.value || !screenshotUrl.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = screenshotUrl.value
|
||||||
|
anchor.download = task.value?.status === 'redeemed'
|
||||||
|
? `manual-redeem-result-${task.value?.taskNo || 'task'}.png`
|
||||||
|
: `manual-redeem-review-${task.value?.taskNo || 'task'}.png`
|
||||||
|
anchor.click()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeImageBlobToClipboard(blob: Blob, fallbackMessage: string) {
|
||||||
|
if (
|
||||||
|
typeof navigator === 'undefined'
|
||||||
|
|| !navigator.clipboard
|
||||||
|
|| typeof window === 'undefined'
|
||||||
|
|| !('ClipboardItem' in window)
|
||||||
|
) {
|
||||||
|
throw new Error('当前浏览器不支持直接复制图片,请改用截图发送或下载')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ClipboardItemCtor = window.ClipboardItem as typeof ClipboardItem
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItemCtor({
|
||||||
|
[blob.type || 'image/png']: blob,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Error ? error : new Error(fallbackMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScreenshotPreview(nextDetail = detail.value) {
|
||||||
|
const taskId = Number(nextDetail?.task?.taskId || 0)
|
||||||
|
const shouldFetch = Boolean(taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady))
|
||||||
|
|
||||||
|
if (!shouldFetch) {
|
||||||
|
clearScreenshotPreview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await fetchAdminTaskScreenshot(taskId)
|
||||||
|
clearScreenshotPreview()
|
||||||
|
screenshotBlob.value = blob
|
||||||
|
screenshotUrl.value = URL.createObjectURL(blob)
|
||||||
|
} catch {
|
||||||
|
clearScreenshotPreview()
|
||||||
|
} finally {
|
||||||
|
screenshotLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScreenshotPreview() {
|
||||||
|
screenshotBlob.value = null
|
||||||
|
|
||||||
|
if (screenshotUrl.value) {
|
||||||
|
URL.revokeObjectURL(screenshotUrl.value)
|
||||||
|
screenshotUrl.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSkuName(skuCode: string) {
|
||||||
|
const normalized = String(skuCode || '').trim()
|
||||||
|
const matched = suggestions.value.find((item) => String(item.skuCode || '').trim() === normalized)
|
||||||
|
return matched?.skuCode || normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
const tokenId = ++pollToken
|
||||||
|
|
||||||
|
const loop = async () => {
|
||||||
|
if (tokenId !== pollToken || !task.value?.taskId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSessionSummary({ silent: true })
|
||||||
|
|
||||||
|
if (tokenId !== pollToken || !shouldKeepPolling(detail.value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pollTimer = setTimeout(() => {
|
||||||
|
void loop()
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pollTimer = setTimeout(() => {
|
||||||
|
void loop()
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartPollingIfNeeded(nextDetail = detail.value) {
|
||||||
|
resetPolling()
|
||||||
|
|
||||||
|
if (shouldKeepPolling(nextDetail)) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPolling() {
|
||||||
|
pollToken += 1
|
||||||
|
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPollingWarning() {
|
||||||
|
pollFailureCount = 0
|
||||||
|
pollWarningMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSilentPollingError(error: unknown) {
|
||||||
|
console.error(error)
|
||||||
|
pollFailureCount += 1
|
||||||
|
|
||||||
|
if (pollFailureCount < POLL_FAILURE_LIMIT) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pollWarningMessage.value = '人工兑换状态刷新失败,已暂停自动轮询,请手动刷新。'
|
||||||
|
resetPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
const qrDisplayWidth = computed(() => {
|
||||||
|
const naturalWidth = qrImageNaturalWidth.value
|
||||||
|
|
||||||
|
if (!naturalWidth) {
|
||||||
|
return 220
|
||||||
|
}
|
||||||
|
|
||||||
|
if (naturalWidth < 160) {
|
||||||
|
return Math.min(naturalWidth * 2, 220)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(naturalWidth, 240)
|
||||||
|
})
|
||||||
|
|
||||||
|
const qrFigureStyle = computed(() => ({
|
||||||
|
width: `${qrDisplayWidth.value}px`,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const qrPreviewWidth = computed(() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
suggestions,
|
||||||
|
detail,
|
||||||
|
detailLoading,
|
||||||
|
createLoading,
|
||||||
|
sessionLoading,
|
||||||
|
roleConfirmLoading,
|
||||||
|
redeemLoading,
|
||||||
|
closeLoading,
|
||||||
|
loginType,
|
||||||
|
loginTypeLabel,
|
||||||
|
loginTabs,
|
||||||
|
session,
|
||||||
|
task,
|
||||||
|
order,
|
||||||
|
orderItem,
|
||||||
|
manualRequest,
|
||||||
|
activityInfo,
|
||||||
|
roleReady,
|
||||||
|
roleConfirmed,
|
||||||
|
hasSession,
|
||||||
|
qrImage,
|
||||||
|
qrFigureStyle,
|
||||||
|
qrPreviewWidth,
|
||||||
|
statusLabel,
|
||||||
|
sessionNotice,
|
||||||
|
roleFacts,
|
||||||
|
resultFacts,
|
||||||
|
canConfirmRole,
|
||||||
|
canRedeem,
|
||||||
|
redeemBlockedReason,
|
||||||
|
redeemButtonLabel,
|
||||||
|
initButtonLabel,
|
||||||
|
scanInstruction,
|
||||||
|
screenshotEmptyTitle,
|
||||||
|
screenshotEmptyMessage,
|
||||||
|
screenshotUrl,
|
||||||
|
screenshotLoading,
|
||||||
|
reviewReady,
|
||||||
|
resultReady,
|
||||||
|
canCreateTask,
|
||||||
|
hasActiveTask,
|
||||||
|
createTaskNow,
|
||||||
|
loadSkuSuggestions,
|
||||||
|
createSessionFlow,
|
||||||
|
refreshSessionFlow,
|
||||||
|
closeSessionFlow,
|
||||||
|
switchLoginType,
|
||||||
|
confirmRoleNow,
|
||||||
|
redeemNow,
|
||||||
|
closeTaskNow,
|
||||||
|
resetCurrentTask,
|
||||||
|
handleQrImageLoad,
|
||||||
|
copyQrImage,
|
||||||
|
copyScreenshot,
|
||||||
|
downloadScreenshot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLoginTypeFromDetail(detail: AdminManualRedeemDetail, fallback: TencentLoginType = DEFAULT_LOGIN_TYPE) {
|
||||||
|
const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim()
|
||||||
|
if (nextLoginType === 'wx') {
|
||||||
|
return 'wx'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextLoginType === 'qq') {
|
||||||
|
return 'qq'
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldKeepPolling(detail: AdminManualRedeemDetail | null) {
|
||||||
|
if (!detail?.task?.taskId || !detail.session?.sessionId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ACTIVE_TASK_STATUSES.has(String(detail.task.status || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskStatusLabel(taskStatus?: string, sessionStatus?: string) {
|
||||||
|
switch (taskStatus) {
|
||||||
|
case 'link_generated':
|
||||||
|
return '待生成二维码'
|
||||||
|
case 'claimed':
|
||||||
|
if (sessionStatus === 'scanned') {
|
||||||
|
return '客户待确认'
|
||||||
|
}
|
||||||
|
if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') {
|
||||||
|
return '已登录待复核'
|
||||||
|
}
|
||||||
|
return '等待客户登录'
|
||||||
|
case 'role_confirmed':
|
||||||
|
return '角色已确认'
|
||||||
|
case 'redeeming':
|
||||||
|
return '正在兑换'
|
||||||
|
case 'redeemed':
|
||||||
|
return '兑换完成'
|
||||||
|
case 'waiting_inventory':
|
||||||
|
return '等待库存'
|
||||||
|
case 'retry_pending':
|
||||||
|
return '等待重试'
|
||||||
|
case 'closed':
|
||||||
|
return '任务已关闭'
|
||||||
|
default:
|
||||||
|
return sessionStatus || taskStatus || '等待中'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDetail(current: AdminManualRedeemDetail | null, next: AdminManualRedeemDetail) {
|
||||||
|
if (next.session === null) {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
session: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current?.session) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
session: {
|
||||||
|
...current.session,
|
||||||
|
...next.session,
|
||||||
|
qrImageBase64:
|
||||||
|
typeof next.session.qrImageBase64 === 'string'
|
||||||
|
? next.session.qrImageBase64
|
||||||
|
: current.session.qrImageBase64,
|
||||||
|
activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null,
|
||||||
|
review: next.session.review ?? current.session.review ?? null,
|
||||||
|
redeem: next.session.redeem ?? current.session.redeem ?? null,
|
||||||
|
artifacts: next.session.artifacts || current.session.artifacts,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRefreshQrImage(currentSession: AdminManualRedeemDetail['session'], nextSession: AdminManualRedeemDetail['session']) {
|
||||||
|
if (!currentSession || !nextSession || !nextSession.artifacts?.hasQrImage) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSession.qrImageBase64) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(nextSession.qrUpdatedAt && nextSession.qrUpdatedAt !== currentSession.qrUpdatedAt)
|
||||||
|
}
|
||||||
@@ -57,6 +57,10 @@ const router = createRouter({
|
|||||||
path: 'tasks',
|
path: 'tasks',
|
||||||
component: () => import('@/views/admin/AdminTasksView.vue'),
|
component: () => import('@/views/admin/AdminTasksView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'manual-redeem',
|
||||||
|
component: () => import('@/views/admin/AdminManualRedeemView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'tasks/:taskId',
|
path: 'tasks/:taskId',
|
||||||
component: () => import('@/views/admin/AdminTaskDetailView.vue'),
|
component: () => import('@/views/admin/AdminTaskDetailView.vue'),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiGet, apiGetBlob, apiPost } from '@/lib/http'
|
import { apiDelete, apiGet, apiGetBlob, apiPost } from '@/lib/http'
|
||||||
import type {
|
import type {
|
||||||
AdminAgisoMessagingDefaults,
|
AdminAgisoMessagingDefaults,
|
||||||
AdminAgisoObservedShopItem,
|
AdminAgisoObservedShopItem,
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
AdminTaskActionResponse,
|
AdminTaskActionResponse,
|
||||||
AdminDashboardSummary,
|
AdminDashboardSummary,
|
||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
|
AdminManualRedeemDetail,
|
||||||
AdminOrderDetail,
|
AdminOrderDetail,
|
||||||
AdminOrderListItem,
|
AdminOrderListItem,
|
||||||
AdminPagination,
|
AdminPagination,
|
||||||
@@ -184,6 +185,54 @@ export function fetchAdminInventorySkuSuggestions(params?: Record<string, unknow
|
|||||||
return apiGet<{ items: AdminInventorySkuSuggestion[] }>('/api/v1/admin/inventory/sku-suggestions', params)
|
return apiGet<{ items: AdminInventorySkuSuggestion[] }>('/api/v1/admin/inventory/sku-suggestions', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAdminManualRedeemSkuSuggestions(params?: Record<string, unknown>) {
|
||||||
|
return apiGet<{ items: AdminInventorySkuSuggestion[] }>('/api/v1/admin/manual-redeem/sku-suggestions', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminManualRedeemTask(payload: {
|
||||||
|
proofValue: string
|
||||||
|
skuCode: string
|
||||||
|
skuName?: string
|
||||||
|
remark?: string
|
||||||
|
}) {
|
||||||
|
return apiPost<AdminManualRedeemDetail>('/api/v1/admin/manual-redeem', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminManualRedeemDetail(taskId: number | string) {
|
||||||
|
return apiGet<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminManualRedeemSession(
|
||||||
|
taskId: number | string,
|
||||||
|
payload: { loginType: string; forceRecreate?: boolean },
|
||||||
|
) {
|
||||||
|
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/session`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminManualRedeemSessionSummary(taskId: number | string) {
|
||||||
|
return apiGet<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/session/summary`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshAdminManualRedeemSession(taskId: number | string) {
|
||||||
|
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/session/refresh`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAdminManualRedeemSession(taskId: number | string) {
|
||||||
|
return apiDelete<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmAdminManualRedeemRole(taskId: number | string) {
|
||||||
|
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/confirm-role`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redeemAdminManualRedeem(taskId: number | string) {
|
||||||
|
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/redeem`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeAdminManualRedeemTask(taskId: number | string) {
|
||||||
|
return apiPost<AdminManualRedeemDetail>(`/api/v1/admin/manual-redeem/${taskId}/close`, {})
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAdminMessageDeliveries(params?: Record<string, unknown>) {
|
export function fetchAdminMessageDeliveries(params?: Record<string, unknown>) {
|
||||||
return apiGet<{ items: AdminMessageDeliveryListItem[]; pagination: AdminPagination }>(
|
return apiGet<{ items: AdminMessageDeliveryListItem[]; pagination: AdminPagination }>(
|
||||||
'/api/v1/admin/message-deliveries',
|
'/api/v1/admin/message-deliveries',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ClaimDetailData } from './claim'
|
||||||
|
|
||||||
export interface AdminPagination {
|
export interface AdminPagination {
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@@ -277,6 +279,14 @@ export interface AdminTaskActionResponse {
|
|||||||
token?: string
|
token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminManualRedeemDetail extends ClaimDetailData {
|
||||||
|
manualRequest: {
|
||||||
|
sourceType: string
|
||||||
|
proofValue: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminOrderDetail {
|
export interface AdminOrderDetail {
|
||||||
order: {
|
order: {
|
||||||
orderId: number
|
orderId: number
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ export interface TencentBrowserSessionArtifacts {
|
|||||||
hasScreenshot: boolean
|
hasScreenshot: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TencentBrowserReviewInfo {
|
||||||
|
capturedAt: string
|
||||||
|
roleId: string
|
||||||
|
roleName: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TencentBrowserRoleInfo {
|
export interface TencentBrowserRoleInfo {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
roleName: string
|
roleName: string
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { TencentBrowserActivityInfo, TencentBrowserSessionArtifacts } from './activity'
|
import type {
|
||||||
|
TencentBrowserActivityInfo,
|
||||||
|
TencentBrowserReviewInfo,
|
||||||
|
TencentBrowserSessionArtifacts,
|
||||||
|
} from './activity'
|
||||||
import type { TencentBrowserRedeemResult } from './redeem'
|
import type { TencentBrowserRedeemResult } from './redeem'
|
||||||
|
|
||||||
export type TencentLoginType = 'qq' | 'wx'
|
export type TencentLoginType = 'qq' | 'wx'
|
||||||
@@ -28,6 +32,7 @@ export interface TencentBrowserSessionData {
|
|||||||
qrUpdatedAt: string
|
qrUpdatedAt: string
|
||||||
credentialReady: boolean
|
credentialReady: boolean
|
||||||
activityInfo?: TencentBrowserActivityInfo | null
|
activityInfo?: TencentBrowserActivityInfo | null
|
||||||
|
review?: TencentBrowserReviewInfo | null
|
||||||
redeem?: TencentBrowserRedeemResult | null
|
redeem?: TencentBrowserRedeemResult | null
|
||||||
artifacts: TencentBrowserSessionArtifacts
|
artifacts: TencentBrowserSessionArtifacts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const navItems = computed(() => {
|
|||||||
{ to: '/admin/dashboard', label: '概览' },
|
{ to: '/admin/dashboard', label: '概览' },
|
||||||
{ to: '/admin/orders', label: '订单' },
|
{ to: '/admin/orders', label: '订单' },
|
||||||
{ to: '/admin/tasks', label: '任务' },
|
{ to: '/admin/tasks', label: '任务' },
|
||||||
|
{ to: '/admin/manual-redeem', label: '人工兑换' },
|
||||||
{ to: '/admin/message-deliveries', label: '消息发送' },
|
{ to: '/admin/message-deliveries', label: '消息发送' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
|
||||||
|
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
|
||||||
|
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
|
||||||
|
import { useAdminManualRedeemPage } from '@/composables/useAdminManualRedeemPage'
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
suggestions,
|
||||||
|
detail,
|
||||||
|
detailLoading,
|
||||||
|
createLoading,
|
||||||
|
sessionLoading,
|
||||||
|
roleConfirmLoading,
|
||||||
|
redeemLoading,
|
||||||
|
closeLoading,
|
||||||
|
loginType,
|
||||||
|
loginTypeLabel,
|
||||||
|
loginTabs,
|
||||||
|
task,
|
||||||
|
order,
|
||||||
|
orderItem,
|
||||||
|
manualRequest,
|
||||||
|
hasSession,
|
||||||
|
qrImage,
|
||||||
|
qrFigureStyle,
|
||||||
|
qrPreviewWidth,
|
||||||
|
statusLabel,
|
||||||
|
session,
|
||||||
|
sessionNotice,
|
||||||
|
roleFacts,
|
||||||
|
resultFacts,
|
||||||
|
roleConfirmed,
|
||||||
|
roleReady,
|
||||||
|
canConfirmRole,
|
||||||
|
canRedeem,
|
||||||
|
redeemBlockedReason,
|
||||||
|
redeemButtonLabel,
|
||||||
|
initButtonLabel,
|
||||||
|
scanInstruction,
|
||||||
|
screenshotEmptyTitle,
|
||||||
|
screenshotEmptyMessage,
|
||||||
|
screenshotUrl,
|
||||||
|
screenshotLoading,
|
||||||
|
reviewReady,
|
||||||
|
resultReady,
|
||||||
|
canCreateTask,
|
||||||
|
hasActiveTask,
|
||||||
|
createTaskNow,
|
||||||
|
createSessionFlow,
|
||||||
|
refreshSessionFlow,
|
||||||
|
closeSessionFlow,
|
||||||
|
switchLoginType,
|
||||||
|
confirmRoleNow,
|
||||||
|
redeemNow,
|
||||||
|
closeTaskNow,
|
||||||
|
resetCurrentTask,
|
||||||
|
handleQrImageLoad,
|
||||||
|
copyQrImage,
|
||||||
|
copyScreenshot,
|
||||||
|
downloadScreenshot,
|
||||||
|
} = useAdminManualRedeemPage()
|
||||||
|
|
||||||
|
const qrPreviewVisible = ref(false)
|
||||||
|
const screenshotPreviewVisible = ref(false)
|
||||||
|
|
||||||
|
const currentLoginTabLabel = computed(
|
||||||
|
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
|
||||||
|
)
|
||||||
|
|
||||||
|
const reviewCardTitle = computed(() => (task.value?.status === 'redeemed' ? '兑换截图' : '角色复核图'))
|
||||||
|
const canStartNextTask = computed(() => Boolean(task.value && ['redeemed', 'closed'].includes(task.value.status)))
|
||||||
|
const taskDetailLink = computed(() => (task.value?.taskId ? `/admin/tasks/${task.value.taskId}` : ''))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-panel manual-redeem-panel">
|
||||||
|
<header class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h1>人工兑换</h1>
|
||||||
|
<p>客服在后台创建唯一任务、发送二维码、复核角色并完成兑换。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<RouterLink v-if="taskDetailLink" :to="taskDetailLink" class="task-link">
|
||||||
|
查看任务详情
|
||||||
|
</RouterLink>
|
||||||
|
<el-button
|
||||||
|
v-if="hasActiveTask"
|
||||||
|
:loading="closeLoading"
|
||||||
|
plain
|
||||||
|
type="danger"
|
||||||
|
@click="closeTaskNow"
|
||||||
|
>
|
||||||
|
关闭当前任务
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canStartNextTask"
|
||||||
|
plain
|
||||||
|
type="primary"
|
||||||
|
@click="resetCurrentTask"
|
||||||
|
>
|
||||||
|
开始下一单
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="create-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h2>1. 创建人工兑换任务</h2>
|
||||||
|
<p>先录入唯一凭据并锁定库存,后续二维码、角色确认和兑换都基于这条任务继续。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<span>唯一凭据</span>
|
||||||
|
<input
|
||||||
|
v-model="form.proofValue"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="输入订单号、客户名或其他唯一可追踪信息"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span>内部履约 SKU</span>
|
||||||
|
<input
|
||||||
|
v-model="form.skuCode"
|
||||||
|
list="manual-redeem-sku-suggestions"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="选择或输入内部履约 SKU"
|
||||||
|
/>
|
||||||
|
<datalist id="manual-redeem-sku-suggestions">
|
||||||
|
<option
|
||||||
|
v-for="item in suggestions"
|
||||||
|
:key="`${item.credentialType}-${item.skuCode}`"
|
||||||
|
:value="item.skuCode"
|
||||||
|
:label="`${item.skuCode} · 可用 ${item.availableCount} / 共 ${item.totalCount}`"
|
||||||
|
/>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field-remark">
|
||||||
|
<span>备注</span>
|
||||||
|
<textarea
|
||||||
|
v-model="form.remark"
|
||||||
|
class="text-input text-area"
|
||||||
|
placeholder="可选:记录客户补充信息、兑换要求或客服备注"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<el-button
|
||||||
|
:disabled="!canCreateTask"
|
||||||
|
:loading="createLoading"
|
||||||
|
round
|
||||||
|
type="primary"
|
||||||
|
@click="createTaskNow"
|
||||||
|
>
|
||||||
|
创建任务并预占库存
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="detail" class="summary-card">
|
||||||
|
<article>
|
||||||
|
<span>唯一凭据</span>
|
||||||
|
<strong>{{ manualRequest?.proofValue || order?.platformOrderId || '-' }}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>任务号</span>
|
||||||
|
<strong>{{ task?.taskNo || '-' }}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>商品</span>
|
||||||
|
<strong>{{ orderItem?.skuName || '-' }}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>当前状态</span>
|
||||||
|
<strong>{{ statusLabel }}</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="detailLoading && !detail" class="empty-block">
|
||||||
|
人工兑换详情加载中
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="detail" class="layout-grid">
|
||||||
|
<TencentAuthCard
|
||||||
|
:current-login-tab-label="currentLoginTabLabel"
|
||||||
|
:has-session="hasSession"
|
||||||
|
:init-button-label="initButtonLabel"
|
||||||
|
:login-tabs="loginTabs"
|
||||||
|
:login-type="loginType"
|
||||||
|
:login-type-label="loginTypeLabel"
|
||||||
|
qr-copy-button-label="复制当前二维码"
|
||||||
|
:qr-copy-enabled="true"
|
||||||
|
:qr-figure-style="qrFigureStyle"
|
||||||
|
:qr-image="qrImage"
|
||||||
|
:scan-instruction="scanInstruction"
|
||||||
|
:session-status="session?.status || ''"
|
||||||
|
:session-id="session?.sessionId || ''"
|
||||||
|
:session-loading="sessionLoading"
|
||||||
|
:session-notice="sessionNotice"
|
||||||
|
@create-session="createSessionFlow"
|
||||||
|
@close-session="closeSessionFlow"
|
||||||
|
@copy-qr="copyQrImage"
|
||||||
|
@open-qr-preview="qrPreviewVisible = true"
|
||||||
|
@qr-image-load="handleQrImageLoad"
|
||||||
|
@reload-session="refreshSessionFlow"
|
||||||
|
@switch-login-type="switchLoginType"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="control-column">
|
||||||
|
<TencentRedeemPanel
|
||||||
|
:can-redeem="canRedeem"
|
||||||
|
:can-confirm-role="canConfirmRole"
|
||||||
|
confirm-action-label="确认当前角色"
|
||||||
|
:confirm-action-loading="roleConfirmLoading"
|
||||||
|
:confirm-action-visible="!roleConfirmed"
|
||||||
|
:confirm-readonly="false"
|
||||||
|
:hide-redeem-form="true"
|
||||||
|
:login-type-label="loginTypeLabel"
|
||||||
|
:max-attempts="6"
|
||||||
|
:redeem-blocked-reason="redeemBlockedReason"
|
||||||
|
:redeem-button-label="redeemButtonLabel"
|
||||||
|
:redeem-code="''"
|
||||||
|
:redeem-loading="redeemLoading"
|
||||||
|
:role-confirmed="roleConfirmed"
|
||||||
|
:role-facts="roleFacts"
|
||||||
|
:role-ready="roleReady"
|
||||||
|
:status-label="statusLabel"
|
||||||
|
@confirm-role="confirmRoleNow"
|
||||||
|
@redeem="redeemNow"
|
||||||
|
@update:max-attempts="() => undefined"
|
||||||
|
@update:redeem-code="() => undefined"
|
||||||
|
@update:role-confirmed="() => undefined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="detail" class="review-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h2>2. {{ reviewCardTitle }}</h2>
|
||||||
|
<p v-if="reviewReady">
|
||||||
|
客户登录后,系统会把当前角色截图回传到这里。客服复制后可直接发给客户做二次确认。
|
||||||
|
</p>
|
||||||
|
<p v-else-if="resultReady">
|
||||||
|
最终兑换完成后,客服可以把结果截图直接发给客户确认。
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
{{ screenshotEmptyMessage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button
|
||||||
|
:disabled="!screenshotUrl || screenshotLoading"
|
||||||
|
plain
|
||||||
|
@click="copyScreenshot"
|
||||||
|
>
|
||||||
|
复制图片
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:disabled="!screenshotUrl || screenshotLoading"
|
||||||
|
plain
|
||||||
|
@click="downloadScreenshot"
|
||||||
|
>
|
||||||
|
下载图片
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="screenshotUrl" class="review-stage">
|
||||||
|
<button class="screenshot-button" type="button" @click="screenshotPreviewVisible = true">
|
||||||
|
<img :src="screenshotUrl" alt="manual redeem screenshot" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-block">
|
||||||
|
<strong>{{ screenshotEmptyTitle }}</strong>
|
||||||
|
<p>{{ screenshotEmptyMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<TencentResultPanel
|
||||||
|
v-if="detail && task?.status === 'redeemed'"
|
||||||
|
:result-facts="resultFacts"
|
||||||
|
:screenshot-empty-message="screenshotEmptyMessage"
|
||||||
|
:screenshot-empty-title="screenshotEmptyTitle"
|
||||||
|
:screenshot-url="screenshotUrl"
|
||||||
|
:show-screenshot="Boolean(screenshotUrl)"
|
||||||
|
@open-screenshot-preview="screenshotPreviewVisible = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section v-else-if="!detail && !detailLoading" class="empty-block">
|
||||||
|
<strong>还没有进行中的人工兑换任务</strong>
|
||||||
|
<p>先在上方输入唯一凭据和内部 SKU,系统会立即校验唯一性并预占库存。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="qrPreviewVisible"
|
||||||
|
align-center
|
||||||
|
class="preview-dialog"
|
||||||
|
:width="qrPreviewWidth"
|
||||||
|
>
|
||||||
|
<div class="qr-preview-body">
|
||||||
|
<img
|
||||||
|
class="preview-image qr-preview-image"
|
||||||
|
:src="qrImage"
|
||||||
|
:alt="`Admin Manual Redeem ${loginTypeLabel} QR Preview`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="screenshotPreviewVisible"
|
||||||
|
align-center
|
||||||
|
class="preview-dialog"
|
||||||
|
width="1080px"
|
||||||
|
>
|
||||||
|
<img class="preview-image" :src="screenshotUrl" alt="Manual redeem screenshot preview" />
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.manual-redeem-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header,
|
||||||
|
.header-actions,
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-link {
|
||||||
|
color: #245db6;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-card,
|
||||||
|
.summary-card,
|
||||||
|
.review-card {
|
||||||
|
padding: 22px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||||
|
box-shadow: 0 20px 60px rgba(30, 49, 78, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #203551;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
color: #4a607f;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-remark {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
background: #f8fbff;
|
||||||
|
color: #21344d;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-area {
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card article {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card span {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #708096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #203551;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(340px, 392px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-column {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-stage {
|
||||||
|
margin-top: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-button img,
|
||||||
|
.preview-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: 1px dashed #d5dfec;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block strong {
|
||||||
|
color: #24384f;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1140px) {
|
||||||
|
.summary-card,
|
||||||
|
.layout-grid,
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user