增加人工渠道
This commit is contained in:
@@ -4,6 +4,7 @@ import authRouter from './admin/auth.js'
|
||||
import auditLogsRouter from './admin/audit-logs.js'
|
||||
import dashboardRouter from './admin/dashboard.js'
|
||||
import inventoryRouter from './admin/inventory.js'
|
||||
import manualRedeemRouter from './admin/manual-redeem.js'
|
||||
import messageDeliveriesRouter from './admin/message-deliveries.js'
|
||||
import ordersRouter from './admin/orders.js'
|
||||
import platformConfigRouter from './admin/platform-config.js'
|
||||
@@ -23,6 +24,7 @@ router.use(auditLogsRouter)
|
||||
router.use(platformConfigRouter)
|
||||
router.use(ordersRouter)
|
||||
router.use(tasksRouter)
|
||||
router.use(manualRedeemRouter)
|
||||
router.use(inventoryRouter)
|
||||
router.use(messageDeliveriesRouter)
|
||||
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 = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
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) {
|
||||
const context = await getClaimContext(token)
|
||||
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) {
|
||||
const context = await getClaimContext(token)
|
||||
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) {
|
||||
const context = await getClaimContext(token)
|
||||
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) {
|
||||
const context = await getClaimContext(token)
|
||||
assertPublicClaimActionAllowed(context.task, 'confirm')
|
||||
|
||||
@@ -58,6 +58,10 @@ export {}
|
||||
* @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./admin-write-inputs.js').AdminManualRedeemCreateInput} AdminManualRedeemCreateRouteBody
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ inventoryItemId?: string }} AdminInventoryRouteParams
|
||||
*/
|
||||
@@ -70,6 +74,10 @@ export {}
|
||||
* @typedef {{ taskId?: string, bindingId?: string }} AdminTaskRouteParams
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ taskId?: string }} AdminManualRedeemRouteParams
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ eventId?: string }} AdminWebhookEventRouteParams
|
||||
*/
|
||||
|
||||
@@ -107,3 +107,12 @@ export {}
|
||||
* deliveredCredential?: string
|
||||
* }} AdminTaskManualDispatchInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* proofValue?: string
|
||||
* skuCode?: string
|
||||
* skuName?: string
|
||||
* remark?: string
|
||||
* }} AdminManualRedeemCreateInput
|
||||
*/
|
||||
|
||||
@@ -63,8 +63,10 @@ export {}
|
||||
* role_name: string
|
||||
* role_id: string
|
||||
* claim_token: string
|
||||
* claim_expires_at?: string | null
|
||||
* primary_claim_token: string
|
||||
* primary_claim_token_id: number | null
|
||||
* primary_claim_expires_at?: string | null
|
||||
* primary_inventory_item_id: number | null
|
||||
* inventory_display_value: string
|
||||
* primary_inventory_display_value: string
|
||||
|
||||
@@ -13,6 +13,8 @@ const props = defineProps<{
|
||||
loginTabs: LoginTab[]
|
||||
loginType: TencentLoginType
|
||||
loginTypeLabel: string
|
||||
qrCopyButtonLabel?: string
|
||||
qrCopyEnabled?: boolean
|
||||
qrFigureStyle: { width: string; maxWidth: string }
|
||||
qrImage: string
|
||||
scanInstruction: string
|
||||
@@ -24,6 +26,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
closeSession: []
|
||||
copyQr: []
|
||||
createSession: [loginType: TencentLoginType]
|
||||
openQrPreview: []
|
||||
qrImageLoad: [event: Event]
|
||||
@@ -53,6 +56,7 @@ const emit = defineEmits<{
|
||||
:style="props.qrFigureStyle"
|
||||
type="button"
|
||||
@click="emit('openQrPreview')"
|
||||
@dblclick.prevent.stop="emit('copyQr')"
|
||||
>
|
||||
<img
|
||||
:src="props.qrImage"
|
||||
@@ -91,6 +95,12 @@ const emit = defineEmits<{
|
||||
<p class="scan-hint">
|
||||
{{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}
|
||||
</p>
|
||||
<p
|
||||
v-if="props.qrCopyEnabled && props.qrImage && props.sessionStatus === 'waiting_scan'"
|
||||
class="scan-tip"
|
||||
>
|
||||
双击二维码可直接复制到剪贴板,也可以使用下方按钮复制。
|
||||
</p>
|
||||
<p class="scan-note">
|
||||
{{
|
||||
props.sessionNotice ||
|
||||
@@ -118,6 +128,17 @@ const emit = defineEmits<{
|
||||
{{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }}
|
||||
</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">
|
||||
<el-button
|
||||
:disabled="!props.hasSession"
|
||||
@@ -308,6 +329,13 @@ const emit = defineEmits<{
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.scan-tip {
|
||||
margin: 8px 0 0;
|
||||
color: #2859b8;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
@@ -374,6 +402,7 @@ const emit = defineEmits<{
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.copy-action,
|
||||
.secondary-action,
|
||||
.tertiary-action {
|
||||
min-width: 0;
|
||||
@@ -389,6 +418,7 @@ const emit = defineEmits<{
|
||||
}
|
||||
|
||||
.action-row :deep(.secondary-action.el-button),
|
||||
.action-row :deep(.copy-action.el-button),
|
||||
.action-row :deep(.tertiary-action.el-button) {
|
||||
background: rgba(248, 251, 255, 0.98);
|
||||
border: 1px solid #d9e4f2;
|
||||
@@ -396,6 +426,8 @@ const emit = defineEmits<{
|
||||
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:focus-visible) {
|
||||
border-color: #9dc0f6;
|
||||
@@ -410,6 +442,7 @@ const emit = defineEmits<{
|
||||
background: #fff6f6;
|
||||
}
|
||||
|
||||
.action-row :deep(.copy-action.el-button.is-disabled),
|
||||
.action-row :deep(.secondary-action.el-button.is-disabled),
|
||||
.action-row :deep(.tertiary-action.el-button.is-disabled) {
|
||||
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',
|
||||
component: () => import('@/views/admin/AdminTasksView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'manual-redeem',
|
||||
component: () => import('@/views/admin/AdminManualRedeemView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'tasks/:taskId',
|
||||
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 {
|
||||
AdminAgisoMessagingDefaults,
|
||||
AdminAgisoObservedShopItem,
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
AdminTaskActionResponse,
|
||||
AdminDashboardSummary,
|
||||
AdminLoginResponse,
|
||||
AdminManualRedeemDetail,
|
||||
AdminOrderDetail,
|
||||
AdminOrderListItem,
|
||||
AdminPagination,
|
||||
@@ -184,6 +185,54 @@ export function fetchAdminInventorySkuSuggestions(params?: Record<string, unknow
|
||||
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>) {
|
||||
return apiGet<{ items: AdminMessageDeliveryListItem[]; pagination: AdminPagination }>(
|
||||
'/api/v1/admin/message-deliveries',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ClaimDetailData } from './claim'
|
||||
|
||||
export interface AdminPagination {
|
||||
page: number
|
||||
pageSize: number
|
||||
@@ -277,6 +279,14 @@ export interface AdminTaskActionResponse {
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface AdminManualRedeemDetail extends ClaimDetailData {
|
||||
manualRequest: {
|
||||
sourceType: string
|
||||
proofValue: string
|
||||
remark: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail {
|
||||
order: {
|
||||
orderId: number
|
||||
|
||||
@@ -3,6 +3,12 @@ export interface TencentBrowserSessionArtifacts {
|
||||
hasScreenshot: boolean
|
||||
}
|
||||
|
||||
export interface TencentBrowserReviewInfo {
|
||||
capturedAt: string
|
||||
roleId: string
|
||||
roleName: string
|
||||
}
|
||||
|
||||
export interface TencentBrowserRoleInfo {
|
||||
ready: boolean
|
||||
roleName: string
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { TencentBrowserActivityInfo, TencentBrowserSessionArtifacts } from './activity'
|
||||
import type {
|
||||
TencentBrowserActivityInfo,
|
||||
TencentBrowserReviewInfo,
|
||||
TencentBrowserSessionArtifacts,
|
||||
} from './activity'
|
||||
import type { TencentBrowserRedeemResult } from './redeem'
|
||||
|
||||
export type TencentLoginType = 'qq' | 'wx'
|
||||
@@ -28,6 +32,7 @@ export interface TencentBrowserSessionData {
|
||||
qrUpdatedAt: string
|
||||
credentialReady: boolean
|
||||
activityInfo?: TencentBrowserActivityInfo | null
|
||||
review?: TencentBrowserReviewInfo | null
|
||||
redeem?: TencentBrowserRedeemResult | null
|
||||
artifacts: TencentBrowserSessionArtifacts
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const navItems = computed(() => {
|
||||
{ to: '/admin/dashboard', label: '概览' },
|
||||
{ to: '/admin/orders', label: '订单' },
|
||||
{ to: '/admin/tasks', label: '任务' },
|
||||
{ to: '/admin/manual-redeem', 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