增加人工渠道

This commit is contained in:
yml2213
2026-04-23 11:59:57 +08:00
parent 871d99cc93
commit 512b58856f
16 changed files with 2206 additions and 2 deletions
+2
View File
@@ -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)
}
+4
View File
@@ -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'),
+50 -1
View File
@@ -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',
+10
View File
@@ -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
+6 -1
View File
@@ -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>