From 512b58856f94b6c11b654a152816aaadc0197415 Mon Sep 17 00:00:00 2001
From: yml2213
Date: Thu, 23 Apr 2026 11:59:57 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=BA=E5=B7=A5=E6=B8=A0?=
=?UTF-8?q?=E9=81=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/backend/src/routes/admin.js | 2 +
.../backend/src/routes/admin/manual-redeem.js | 183 ++++
.../admin/admin-manual-redeem-service.js | 376 ++++++++
.../services/claim/claim-session-service.js | 169 ++++
apps/backend/src/types/admin-route-inputs.js | 8 +
apps/backend/src/types/admin-write-inputs.js | 9 +
apps/backend/src/types/repository-rows.js | 2 +
.../components/tencent/TencentAuthCard.vue | 33 +
.../composables/useAdminManualRedeemPage.ts | 836 ++++++++++++++++++
apps/frontend/src/router/index.ts | 4 +
apps/frontend/src/services/admin.ts | 51 +-
apps/frontend/src/types/admin.ts | 10 +
apps/frontend/src/types/tencent/activity.ts | 6 +
apps/frontend/src/types/tencent/session.ts | 7 +-
apps/frontend/src/views/admin/AdminLayout.vue | 1 +
.../src/views/admin/AdminManualRedeemView.vue | 511 +++++++++++
16 files changed, 2206 insertions(+), 2 deletions(-)
create mode 100644 apps/backend/src/routes/admin/manual-redeem.js
create mode 100644 apps/backend/src/services/admin/admin-manual-redeem-service.js
create mode 100644 apps/frontend/src/composables/useAdminManualRedeemPage.ts
create mode 100644 apps/frontend/src/views/admin/AdminManualRedeemView.vue
diff --git a/apps/backend/src/routes/admin.js b/apps/backend/src/routes/admin.js
index 8440306d..a0e80c04 100644
--- a/apps/backend/src/routes/admin.js
+++ b/apps/backend/src/routes/admin.js
@@ -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)
diff --git a/apps/backend/src/routes/admin/manual-redeem.js b/apps/backend/src/routes/admin/manual-redeem.js
new file mode 100644
index 00000000..659ac736
--- /dev/null
+++ b/apps/backend/src/routes/admin/manual-redeem.js
@@ -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
diff --git a/apps/backend/src/services/admin/admin-manual-redeem-service.js b/apps/backend/src/services/admin/admin-manual-redeem-service.js
new file mode 100644
index 00000000..87b428ce
--- /dev/null
+++ b/apps/backend/src/services/admin/admin-manual-redeem-service.js
@@ -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()
+}
diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js
index d6b8d1cd..77a1e1a5 100644
--- a/apps/backend/src/services/claim/claim-session-service.js
+++ b/apps/backend/src/services/claim/claim-session-service.js
@@ -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')
diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js
index 77320725..e7494e47 100644
--- a/apps/backend/src/types/admin-route-inputs.js
+++ b/apps/backend/src/types/admin-route-inputs.js
@@ -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
*/
diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js
index 1ef4622e..9e8a7f7c 100644
--- a/apps/backend/src/types/admin-write-inputs.js
+++ b/apps/backend/src/types/admin-write-inputs.js
@@ -107,3 +107,12 @@ export {}
* deliveredCredential?: string
* }} AdminTaskManualDispatchInput
*/
+
+/**
+ * @typedef {{
+ * proofValue?: string
+ * skuCode?: string
+ * skuName?: string
+ * remark?: string
+ * }} AdminManualRedeemCreateInput
+ */
diff --git a/apps/backend/src/types/repository-rows.js b/apps/backend/src/types/repository-rows.js
index dbedf629..3176906e 100644
--- a/apps/backend/src/types/repository-rows.js
+++ b/apps/backend/src/types/repository-rows.js
@@ -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
diff --git a/apps/frontend/src/components/tencent/TencentAuthCard.vue b/apps/frontend/src/components/tencent/TencentAuthCard.vue
index bb6677b7..ee707871 100644
--- a/apps/frontend/src/components/tencent/TencentAuthCard.vue
+++ b/apps/frontend/src/components/tencent/TencentAuthCard.vue
@@ -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')"
>
{{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}
+
+ 双击二维码可直接复制到剪贴板,也可以使用下方按钮复制。
+
{{
props.sessionNotice ||
@@ -118,6 +128,17 @@ const emit = defineEmits<{
{{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }}
+
+ {{ props.qrCopyButtonLabel || '复制二维码' }}
+
+
([])
+ const detail = ref(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(null)
+ const screenshotUrl = ref('')
+ const screenshotLoading = ref(false)
+ const loginType = ref(DEFAULT_LOGIN_TYPE)
+
+ let pollTimer: ReturnType | 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)
+}
diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts
index 4cddfa1a..9851df80 100644
--- a/apps/frontend/src/router/index.ts
+++ b/apps/frontend/src/router/index.ts
@@ -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'),
diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts
index 53ca5949..e5e5bf55 100644
--- a/apps/frontend/src/services/admin.ts
+++ b/apps/frontend/src/services/admin.ts
@@ -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('/api/v1/admin/inventory/sku-suggestions', params)
}
+export function fetchAdminManualRedeemSkuSuggestions(params?: Record) {
+ 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('/api/v1/admin/manual-redeem', payload)
+}
+
+export function fetchAdminManualRedeemDetail(taskId: number | string) {
+ return apiGet(`/api/v1/admin/manual-redeem/${taskId}`)
+}
+
+export function createAdminManualRedeemSession(
+ taskId: number | string,
+ payload: { loginType: string; forceRecreate?: boolean },
+) {
+ return apiPost(`/api/v1/admin/manual-redeem/${taskId}/session`, payload)
+}
+
+export function fetchAdminManualRedeemSessionSummary(taskId: number | string) {
+ return apiGet(`/api/v1/admin/manual-redeem/${taskId}/session/summary`)
+}
+
+export function refreshAdminManualRedeemSession(taskId: number | string) {
+ return apiPost(`/api/v1/admin/manual-redeem/${taskId}/session/refresh`, {})
+}
+
+export function removeAdminManualRedeemSession(taskId: number | string) {
+ return apiDelete(`/api/v1/admin/manual-redeem/${taskId}/session`)
+}
+
+export function confirmAdminManualRedeemRole(taskId: number | string) {
+ return apiPost(`/api/v1/admin/manual-redeem/${taskId}/confirm-role`, {})
+}
+
+export function redeemAdminManualRedeem(taskId: number | string) {
+ return apiPost(`/api/v1/admin/manual-redeem/${taskId}/redeem`, {})
+}
+
+export function closeAdminManualRedeemTask(taskId: number | string) {
+ return apiPost(`/api/v1/admin/manual-redeem/${taskId}/close`, {})
+}
+
export function fetchAdminMessageDeliveries(params?: Record) {
return apiGet<{ items: AdminMessageDeliveryListItem[]; pagination: AdminPagination }>(
'/api/v1/admin/message-deliveries',
diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts
index 5dc85368..95721605 100644
--- a/apps/frontend/src/types/admin.ts
+++ b/apps/frontend/src/types/admin.ts
@@ -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
diff --git a/apps/frontend/src/types/tencent/activity.ts b/apps/frontend/src/types/tencent/activity.ts
index 3de13bf3..dd66aac3 100644
--- a/apps/frontend/src/types/tencent/activity.ts
+++ b/apps/frontend/src/types/tencent/activity.ts
@@ -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
diff --git a/apps/frontend/src/types/tencent/session.ts b/apps/frontend/src/types/tencent/session.ts
index f4a3b6ee..c88cc83c 100644
--- a/apps/frontend/src/types/tencent/session.ts
+++ b/apps/frontend/src/types/tencent/session.ts
@@ -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
}
diff --git a/apps/frontend/src/views/admin/AdminLayout.vue b/apps/frontend/src/views/admin/AdminLayout.vue
index e56ccc3f..048bc246 100644
--- a/apps/frontend/src/views/admin/AdminLayout.vue
+++ b/apps/frontend/src/views/admin/AdminLayout.vue
@@ -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: '消息发送' },
]
diff --git a/apps/frontend/src/views/admin/AdminManualRedeemView.vue b/apps/frontend/src/views/admin/AdminManualRedeemView.vue
new file mode 100644
index 00000000..6b9b0855
--- /dev/null
+++ b/apps/frontend/src/views/admin/AdminManualRedeemView.vue
@@ -0,0 +1,511 @@
+
+
+
+
+
+
+
+
+
+
1. 创建人工兑换任务
+
先录入唯一凭据并锁定库存,后续二维码、角色确认和兑换都基于这条任务继续。
+
+
+
+
+
+
+
+ 创建任务并预占库存
+
+
+
+
+
+
+ 唯一凭据
+ {{ manualRequest?.proofValue || order?.platformOrderId || '-' }}
+
+
+ 任务号
+ {{ task?.taskNo || '-' }}
+
+
+ 商品
+ {{ orderItem?.skuName || '-' }}
+
+
+ 当前状态
+ {{ statusLabel }}
+
+
+
+
+ 人工兑换详情加载中
+
+
+
+
+
+
+ undefined"
+ @update:redeem-code="() => undefined"
+ @update:role-confirmed="() => undefined"
+ />
+
+
+
+
+
+
+
2. {{ reviewCardTitle }}
+
+ 客户登录后,系统会把当前角色截图回传到这里。客服复制后可直接发给客户做二次确认。
+
+
+ 最终兑换完成后,客服可以把结果截图直接发给客户确认。
+
+
+ {{ screenshotEmptyMessage }}
+
+
+
+
+
+
+
+
+
+
+
{{ screenshotEmptyTitle }}
+
{{ screenshotEmptyMessage }}
+
+
+
+
+
+
+ 还没有进行中的人工兑换任务
+ 先在上方输入唯一凭据和内部 SKU,系统会立即校验唯一性并预占库存。
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+