半自动+人工”链路已经落下第一版
This commit is contained in:
@@ -11,6 +11,8 @@ import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAdminRoles(['admin', 'operator']))
|
||||
|
||||
router.get('/inventory', createJsonHandler(
|
||||
(req) => getAdminInventoryItems(req.query),
|
||||
{
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Router } from 'express'
|
||||
|
||||
import {
|
||||
closeAdminTask,
|
||||
confirmAdminTaskAssistedRole,
|
||||
completeAdminTaskManualDispatch,
|
||||
getAdminTaskDetail,
|
||||
getAdminTaskScreenshotPath,
|
||||
getAdminTasks,
|
||||
markAdminTaskManualReview,
|
||||
regenerateAdminTaskClaimLink,
|
||||
redeemAdminTaskAssisted,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
releaseAdminTaskInventory,
|
||||
retryAdminTask,
|
||||
@@ -17,7 +19,7 @@ import { createFileHandler, createJsonHandler, requireAdminRoles } from './share
|
||||
const router = Router()
|
||||
|
||||
router.get('/tasks', createJsonHandler(
|
||||
(req) => getAdminTasks(req.query),
|
||||
(req) => getAdminTasks(req.query, req.adminSession),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取任务列表失败',
|
||||
@@ -26,7 +28,7 @@ router.get('/tasks', createJsonHandler(
|
||||
))
|
||||
|
||||
router.get('/tasks/:taskId', createJsonHandler(
|
||||
(req) => getAdminTaskDetail(req.params.taskId),
|
||||
(req) => getAdminTaskDetail(req.params.taskId, req.adminSession),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取任务详情失败',
|
||||
@@ -35,14 +37,14 @@ router.get('/tasks/:taskId', createJsonHandler(
|
||||
))
|
||||
|
||||
router.get('/tasks/:taskId/screenshot', createFileHandler(
|
||||
(req) => getAdminTaskScreenshotPath(req.params.taskId),
|
||||
(req) => getAdminTaskScreenshotPath(req.params.taskId, req.adminSession),
|
||||
{
|
||||
errorMessage: '读取任务截图失败',
|
||||
scope: '[admin/tasks/:taskId/screenshot]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/tasks/:taskId/retry', createJsonHandler(
|
||||
router.post('/tasks/:taskId/retry', requireAdminRoles(['admin', 'operator']), createJsonHandler(
|
||||
(req) => retryAdminTask(req.params.taskId),
|
||||
{
|
||||
successMessage: '任务已重试',
|
||||
@@ -90,8 +92,8 @@ router.post('/tasks/:taskId/inventory-bindings/:bindingId/release', requireAdmin
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin']), createJsonHandler(
|
||||
(req) => regenerateAdminTaskClaimLink(req.params.taskId),
|
||||
router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler(
|
||||
(req) => regenerateAdminTaskClaimLink(req.params.taskId, req.adminSession),
|
||||
{
|
||||
successMessage: '领取链接已重新生成',
|
||||
errorMessage: '重新生成领取链接失败',
|
||||
@@ -109,7 +111,46 @@ router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin'])
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/tasks/:taskId/close', requireAdminRoles(['admin']), createJsonHandler(
|
||||
router.post('/tasks/:taskId/support-confirm-role', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler(
|
||||
(req) => confirmAdminTaskAssistedRole(req.params.taskId, req.adminSession),
|
||||
{
|
||||
successMessage: '角色已确认',
|
||||
errorMessage: '客服确认角色失败',
|
||||
scope: '[admin/tasks/:taskId/support-confirm-role]',
|
||||
audit: (req, data) => ({
|
||||
action: 'task_support_confirm_role',
|
||||
targetType: 'task',
|
||||
targetId: String(req.params.taskId),
|
||||
data: {
|
||||
taskId: data.task.taskId,
|
||||
taskNo: data.task.taskNo,
|
||||
status: data.task.status,
|
||||
},
|
||||
}),
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/tasks/:taskId/support-redeem', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler(
|
||||
(req) => redeemAdminTaskAssisted(req.params.taskId, req.adminSession),
|
||||
{
|
||||
successMessage: '兑换任务已启动',
|
||||
errorMessage: '客服发起兑换失败',
|
||||
scope: '[admin/tasks/:taskId/support-redeem]',
|
||||
audit: (req, data) => ({
|
||||
action: 'task_support_redeem',
|
||||
targetType: 'task',
|
||||
targetId: String(req.params.taskId),
|
||||
data: {
|
||||
taskId: data.task.taskId,
|
||||
taskNo: data.task.taskNo,
|
||||
status: data.task.status,
|
||||
deliveryStatus: data.task.deliveryStatus,
|
||||
},
|
||||
}),
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/tasks/:taskId/close', requireAdminRoles(['admin', 'operator']), createJsonHandler(
|
||||
(req) => closeAdminTask(req.params.taskId),
|
||||
{
|
||||
successMessage: '任务已关闭',
|
||||
@@ -128,7 +169,7 @@ router.post('/tasks/:taskId/close', requireAdminRoles(['admin']), createJsonHand
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/tasks/:taskId/mark-manual-review', requireAdminRoles(['admin']), createJsonHandler(
|
||||
router.post('/tasks/:taskId/mark-manual-review', requireAdminRoles(['admin', 'operator']), createJsonHandler(
|
||||
(req) => markAdminTaskManualReview(req.params.taskId),
|
||||
{
|
||||
successMessage: '任务已转人工处理',
|
||||
@@ -147,7 +188,7 @@ router.post('/tasks/:taskId/mark-manual-review', requireAdminRoles(['admin']), c
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/tasks/:taskId/complete-manual-dispatch', requireAdminRoles(['admin']), createJsonHandler(
|
||||
router.post('/tasks/:taskId/complete-manual-dispatch', requireAdminRoles(['admin', 'operator']), createJsonHandler(
|
||||
(req) => completeAdminTaskManualDispatch(req.params.taskId, req.body, req.adminSession),
|
||||
{
|
||||
successMessage: '人工履约结果已回写',
|
||||
|
||||
@@ -9,6 +9,8 @@ import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAdminRoles(['admin', 'operator']))
|
||||
|
||||
router.get('/webhook-events', createJsonHandler(
|
||||
(req) => getAdminWebhookEvents(req.query),
|
||||
{
|
||||
|
||||
@@ -343,7 +343,17 @@ function signPayload(encodedPayload) {
|
||||
}
|
||||
|
||||
export function normalizeAdminRole(role) {
|
||||
return String(role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'operator'
|
||||
const normalized = String(role || '').trim().toLowerCase()
|
||||
|
||||
if (normalized === 'admin') {
|
||||
return 'admin'
|
||||
}
|
||||
|
||||
if (normalized === 'support') {
|
||||
return 'support'
|
||||
}
|
||||
|
||||
return 'operator'
|
||||
}
|
||||
|
||||
export function normalizeAdminUserStatus(status) {
|
||||
@@ -363,7 +373,7 @@ function safeCompare(input, expected) {
|
||||
|
||||
function normalizeRoleQuery(role) {
|
||||
const normalized = String(role || '').trim().toLowerCase()
|
||||
return ['admin', 'operator'].includes(normalized) ? normalized : ''
|
||||
return ['admin', 'operator', 'support'].includes(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function normalizeStatusQuery(status) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { query } from '../../db/client.js'
|
||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../claim/claim-session-service.js'
|
||||
import {
|
||||
getAgisoShopConfig,
|
||||
getAgisoShopConfigMap,
|
||||
@@ -41,6 +42,8 @@ import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js'
|
||||
import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { normalizeProductName } from '../order/product-match-service.js'
|
||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||
import { normalizeAdminRole } from './admin-auth-service.js'
|
||||
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
|
||||
|
||||
export async function getAdminDashboardSummary() {
|
||||
const todayPrefix = nowIso().slice(0, 10)
|
||||
@@ -176,7 +179,7 @@ export async function getAdminOrderDetail(orderId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminTasks(query = {}) {
|
||||
export async function getAdminTasks(query = {}, session = null) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = await listTasks({
|
||||
@@ -191,9 +194,14 @@ export async function getAdminTasks(query = {}) {
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id))
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
|
||||
return {
|
||||
items: items.map((item) => mapAdminTaskListItem(item, getTaskBindingSummary(taskBindingSummaryMap, item.id))),
|
||||
items: items.map((item) => mapAdminTaskListItem(
|
||||
item,
|
||||
getTaskBindingSummary(taskBindingSummaryMap, item.id),
|
||||
viewerContext,
|
||||
)),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
@@ -220,7 +228,7 @@ export async function getAdminMessageDeliveries(query = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminTaskDetail(taskId) {
|
||||
export async function getAdminTaskDetail(taskId, session = null) {
|
||||
const task = await getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
@@ -240,8 +248,12 @@ export async function getAdminTaskDetail(taskId) {
|
||||
const orderItems = order ? await listOrderItemsByOrderId(order.id) : []
|
||||
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
|
||||
const taskContext = parseTaskContext(task)
|
||||
const taskState = parseTaskState(task)
|
||||
const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings)
|
||||
const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task))
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext))
|
||||
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
|
||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
||||
|
||||
return {
|
||||
task: mapAdminTaskListItem({
|
||||
@@ -250,7 +262,7 @@ export async function getAdminTaskDetail(taskId) {
|
||||
sku_name: orderItem?.sku_name || '',
|
||||
inventory_display_value: inventoryItem?.display_value || '',
|
||||
claim_token: claimToken?.token || '',
|
||||
}, taskBindingSummary),
|
||||
}, taskBindingSummary, viewerContext),
|
||||
order: order
|
||||
? {
|
||||
orderId: order.id,
|
||||
@@ -274,9 +286,10 @@ export async function getAdminTaskDetail(taskId) {
|
||||
claimToken: claimToken
|
||||
? {
|
||||
primaryClaimTokenId: claimToken.id,
|
||||
token: claimToken.token,
|
||||
token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '',
|
||||
status: claimToken.status,
|
||||
expiredAt: claimToken.expired_at,
|
||||
claimUrl,
|
||||
}
|
||||
: null,
|
||||
inventory: inventoryItem
|
||||
@@ -285,37 +298,64 @@ export async function getAdminTaskDetail(taskId) {
|
||||
skuCode: inventoryItem.sku_code,
|
||||
batchNo: inventoryItem.batch_no,
|
||||
credentialType: inventoryItem.credential_type || 'tencent_code',
|
||||
displayValue: inventoryItem.display_value,
|
||||
displayValue: viewerContext.canViewSensitiveTaskData ? inventoryItem.display_value : '',
|
||||
status: inventoryItem.status,
|
||||
}
|
||||
: null,
|
||||
inventoryBindings: mappedInventoryBindings,
|
||||
artifacts: safeParseJson(task.artifacts_json),
|
||||
screenshotUrl: task.screenshot_path ? `/api/v1/admin/tasks/${task.id}/screenshot` : '',
|
||||
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch),
|
||||
artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {},
|
||||
screenshotUrl,
|
||||
review: {
|
||||
required: isAssistedClaimTask(task),
|
||||
screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null,
|
||||
roleId: String(taskState.reviewRoleId || '').trim() || '',
|
||||
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
||||
},
|
||||
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
||||
events: taskEvents.map(mapAdminTaskEvent),
|
||||
operations: {
|
||||
canRetry: !isManualDispatchTask(task) && ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
||||
canRetry: viewerContext.canManageTaskLifecycle
|
||||
&& !isManualDispatchTask(task)
|
||||
&& ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
||||
canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease),
|
||||
canRegenerateClaimLink: !isManualDispatchTask(task) && ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status),
|
||||
canClose: !['redeemed', 'closed'].includes(task.task_status),
|
||||
canMarkManualReview: !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
|
||||
canCompleteManualDispatch: isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status),
|
||||
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
|
||||
canClose: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed'].includes(task.task_status),
|
||||
canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
|
||||
canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status),
|
||||
canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext),
|
||||
canSupportRedeem: canViewerRedeemAssistedTask(task, viewerContext),
|
||||
canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminTaskScreenshotPath(taskId) {
|
||||
export async function getAdminTaskScreenshotPath(taskId, session = null) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
|
||||
if (!task.screenshot_path) {
|
||||
throw createHttpError('当前任务还没有截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_screenshot_not_found',
|
||||
})
|
||||
if (viewerContext.role === 'support') {
|
||||
if (!task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有客服复核截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_review_screenshot_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
||||
}
|
||||
|
||||
return task.screenshot_path
|
||||
if (task.screenshot_path) {
|
||||
return task.screenshot_path
|
||||
}
|
||||
|
||||
if (task.browser_session_id) {
|
||||
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
||||
}
|
||||
|
||||
throw createHttpError('当前任务还没有截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_screenshot_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAdminInventoryItems(query = {}) {
|
||||
@@ -925,9 +965,10 @@ export async function releaseAdminTaskInventoryBinding(taskId, bindingId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateAdminTaskClaimLink(taskId) {
|
||||
export async function regenerateAdminTaskClaimLink(taskId, session = null) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
|
||||
if (isManualDispatchTask(task)) {
|
||||
throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', {
|
||||
@@ -943,6 +984,13 @@ export async function regenerateAdminTaskClaimLink(taskId) {
|
||||
})
|
||||
}
|
||||
|
||||
if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) {
|
||||
throw createHttpError('当前账号只能重发半自动客服任务的领取链接', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_task_regenerate_permission_denied',
|
||||
})
|
||||
}
|
||||
|
||||
if (primaryClaimTokenId) {
|
||||
await updateClaimToken(primaryClaimTokenId, {
|
||||
status: 'revoked',
|
||||
@@ -966,6 +1014,30 @@ export async function regenerateAdminTaskClaimLink(taskId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmAdminTaskAssistedRole(taskId, session = null) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm')
|
||||
await confirmClaimRoleForAdminTask(task.id)
|
||||
const updatedTask = await getRequiredTask(task.id)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function redeemAdminTaskAssisted(taskId, session = null) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem')
|
||||
await redeemClaimTaskForAdminTask(task.id)
|
||||
const updatedTask = await getRequiredTask(task.id)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeAdminTask(taskId) {
|
||||
const task = await getRequiredTask(taskId)
|
||||
|
||||
@@ -1387,7 +1459,7 @@ async function getRequiredInventoryItem(inventoryItemId) {
|
||||
return inventoryItem
|
||||
}
|
||||
|
||||
function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary()) {
|
||||
function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary(), viewerContext = createAdminViewerContext()) {
|
||||
const binding = buildTaskBindingState(task)
|
||||
|
||||
return {
|
||||
@@ -1415,10 +1487,10 @@ function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSumma
|
||||
lastError: task.last_error,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
inventoryDisplayMasked: maskCode(task.primary_inventory_display_value),
|
||||
inventoryDisplayMasked: viewerContext.canViewSensitiveTaskData ? maskCode(task.primary_inventory_display_value) : '',
|
||||
inventoryCredentialType: String(task.primary_inventory_credential_type || '').trim(),
|
||||
claimToken: task.primary_claim_token || task.claim_token || '',
|
||||
screenshotPath: task.screenshot_path || '',
|
||||
claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '',
|
||||
screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,7 +1553,7 @@ function createTaskBindingSummaryFromBindings(bindings = []) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminTaskInventoryBinding(binding, task) {
|
||||
function mapAdminTaskInventoryBinding(binding, task, viewerContext = createAdminViewerContext()) {
|
||||
const metadata = safeParseJson(binding.metadata_json)
|
||||
|
||||
return {
|
||||
@@ -1494,7 +1566,7 @@ function mapAdminTaskInventoryBinding(binding, task) {
|
||||
skuCode: String(binding.sku_code || '').trim(),
|
||||
batchNo: String(binding.batch_no || '').trim(),
|
||||
credentialType: String(binding.credential_type || 'tencent_code').trim() || 'tencent_code',
|
||||
displayValue: String(binding.display_value || '').trim(),
|
||||
displayValue: viewerContext.canViewSensitiveTaskData ? String(binding.display_value || '').trim() : '',
|
||||
invalidReason: String(binding.invalid_reason || '').trim(),
|
||||
consumedAt: binding.consumed_at || null,
|
||||
releasedAt: binding.released_at || null,
|
||||
@@ -1502,7 +1574,7 @@ function mapAdminTaskInventoryBinding(binding, task) {
|
||||
updatedAt: binding.updated_at,
|
||||
metadata: normalizeRecord(metadata),
|
||||
isPrimary: Number(binding.inventory_item_id || 0) === getTaskPrimaryInventoryItemId(task),
|
||||
canRelease: canReleaseTaskInventoryBinding(task, binding),
|
||||
canRelease: viewerContext.canManageSensitiveInventory && canReleaseTaskInventoryBinding(task, binding),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1762,7 +1834,25 @@ function parseTaskContext(task) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapManualDispatchContext(value) {
|
||||
function parseTaskState(task) {
|
||||
const value = task?.state_json
|
||||
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function mapManualDispatchContext(value, viewerContext = createAdminViewerContext()) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
@@ -1770,7 +1860,7 @@ function mapManualDispatchContext(value) {
|
||||
return {
|
||||
outcome: String(value.outcome || '').trim(),
|
||||
deliveryReference: String(value.deliveryReference || '').trim(),
|
||||
deliveredCredential: String(value.deliveredCredential || '').trim(),
|
||||
deliveredCredential: viewerContext.canViewSensitiveTaskData ? String(value.deliveredCredential || '').trim() : '',
|
||||
resultMessage: String(value.resultMessage || '').trim(),
|
||||
completedAt: value.completedAt || null,
|
||||
completedBy: value.completedBy && typeof value.completedBy === 'object'
|
||||
@@ -1797,6 +1887,10 @@ function isManualDispatchTask(task) {
|
||||
return String(task?.executor_key || '').trim() === 'manual_dispatch'
|
||||
}
|
||||
|
||||
function isAssistedClaimTask(task) {
|
||||
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
|
||||
}
|
||||
|
||||
function getTaskPrimaryInventoryItemId(task) {
|
||||
const value = Number(task?.primary_inventory_item_id || 0)
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
@@ -1815,6 +1909,87 @@ function getTaskRetryCount(task) {
|
||||
return Number(task?.attempt_count || 0)
|
||||
}
|
||||
|
||||
function createAdminViewerContext(session = null) {
|
||||
const role = normalizeAdminRole(session?.role)
|
||||
|
||||
return {
|
||||
role,
|
||||
canViewSensitiveTaskData: role === 'admin' || role === 'operator',
|
||||
canManageSensitiveInventory: role === 'admin',
|
||||
canManageTaskLifecycle: role === 'admin' || role === 'operator',
|
||||
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
|
||||
}
|
||||
}
|
||||
|
||||
function canRegenerateClaimLinkForViewer(task, viewerContext) {
|
||||
if (isManualDispatchTask(task)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseAllowed = ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status)
|
||||
|
||||
if (!baseAllowed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (viewerContext.canManageTaskLifecycle) {
|
||||
return true
|
||||
}
|
||||
|
||||
return viewerContext.canOperateAssistedTask && isAssistedClaimTask(task)
|
||||
}
|
||||
|
||||
function canViewerConfirmAssistedRole(task, viewerContext) {
|
||||
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return String(task?.task_status || '').trim() === 'claimed'
|
||||
}
|
||||
|
||||
function canViewerRedeemAssistedTask(task, viewerContext) {
|
||||
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim())
|
||||
}
|
||||
|
||||
function ensureViewerCanOperateAssistedTask(task, viewerContext, action) {
|
||||
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) {
|
||||
throw createHttpError('当前账号没有此操作权限', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_task_assisted_permission_denied',
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'confirm' && !canViewerConfirmAssistedRole(task, viewerContext)) {
|
||||
throw createHttpError('当前任务状态还不能确认角色', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_assisted_confirm_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'redeem' && !canViewerRedeemAssistedTask(task, viewerContext)) {
|
||||
throw createHttpError('当前任务状态还不能开始兑换', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_assisted_redeem_not_allowed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAdminTaskScreenshotUrl(task, viewerContext) {
|
||||
if (viewerContext.role === 'support') {
|
||||
return task.browser_session_id ? `/api/v1/admin/tasks/${task.id}/screenshot` : ''
|
||||
}
|
||||
|
||||
if (task.screenshot_path || task.browser_session_id) {
|
||||
return `/api/v1/admin/tasks/${task.id}/screenshot`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveDisplayShopName(provider, shopId, shopName) {
|
||||
const normalizedShopName = String(shopName || '').trim()
|
||||
if (normalizedShopName) {
|
||||
|
||||
@@ -37,6 +37,22 @@ const CORE_PROFILES = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
profileKey: 'tencent_claim_assisted',
|
||||
name: '腾讯领取兑换(半自动+人工)',
|
||||
executorKey: 'tencent_claim_assisted',
|
||||
requiresClaim: true,
|
||||
autoDispatch: false,
|
||||
inventoryStrategy: 'static_pool',
|
||||
requirements: [
|
||||
{
|
||||
roleKey: 'primary_code',
|
||||
credentialType: 'tencent_code',
|
||||
quantityPerUnit: 1,
|
||||
isRequired: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export async function ensureFulfillmentCatalogBootstrapped() {
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
getTencentBrowserSessionScreenshotPath,
|
||||
redeemTencentBrowserSession,
|
||||
} from '../session/session.js'
|
||||
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||
import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { getOrderItemById } from '../../repositories/order-item-repo.js'
|
||||
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
|
||||
import { findTaskByClaimTokenId, getTaskById, updateTask } from '../../repositories/task-repo.js'
|
||||
import {
|
||||
getInventoryItemById,
|
||||
markInventoryItemDelivered,
|
||||
@@ -24,7 +24,7 @@ const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
|
||||
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
const session = await loadTaskSession(context.task, { includeQrImage })
|
||||
const syncedTask = session ? syncTaskWithSession(context.task, session) : context.task
|
||||
const syncedTask = session ? await syncTaskWithSession(context.task, session) : context.task
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
@@ -97,7 +97,18 @@ export async function getClaimSessionSummary(token) {
|
||||
|
||||
export async function confirmClaimRole(token) {
|
||||
const context = await getClaimContext(token)
|
||||
assertPublicClaimActionAllowed(context.task, 'confirm')
|
||||
|
||||
return finalizeClaimRoleConfirmation(context)
|
||||
}
|
||||
|
||||
export async function confirmClaimRoleForAdminTask(taskId) {
|
||||
const context = await getClaimContextByTaskId(taskId)
|
||||
|
||||
return finalizeClaimRoleConfirmation(context)
|
||||
}
|
||||
|
||||
async function finalizeClaimRoleConfirmation(context) {
|
||||
if (!context.task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有创建浏览器会话', {
|
||||
statusCode: 409,
|
||||
@@ -140,7 +151,19 @@ export async function confirmClaimRole(token) {
|
||||
export async function redeemClaimTask(token) {
|
||||
const context = await getClaimContext(token)
|
||||
assertTaskCanProceed(context.task)
|
||||
assertPublicClaimActionAllowed(context.task, 'redeem')
|
||||
|
||||
return finalizeClaimTaskRedeem(context)
|
||||
}
|
||||
|
||||
export async function redeemClaimTaskForAdminTask(taskId) {
|
||||
const context = await getClaimContextByTaskId(taskId)
|
||||
assertTaskCanProceed(context.task)
|
||||
|
||||
return finalizeClaimTaskRedeem(context)
|
||||
}
|
||||
|
||||
async function finalizeClaimTaskRedeem(context) {
|
||||
if (!context.task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有创建浏览器会话', {
|
||||
statusCode: 409,
|
||||
@@ -298,6 +321,54 @@ async function getClaimContext(token) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getClaimContextByTaskId(taskId) {
|
||||
const task = await getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('领取任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const claimTokenId = Number(task.primary_claim_token_id || 0)
|
||||
|
||||
if (!claimTokenId) {
|
||||
throw createHttpError('当前任务还没有领取链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_token_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await getClaimTokenById(claimTokenId)
|
||||
|
||||
if (!claimToken) {
|
||||
throw createHttpError('领取链接不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_token_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const [order, orderItem] = await Promise.all([
|
||||
getOrderById(task.order_id),
|
||||
getOrderItemById(task.order_item_id),
|
||||
])
|
||||
|
||||
if (!order || !orderItem) {
|
||||
throw createHttpError('领取任务关联订单不完整', {
|
||||
statusCode: 500,
|
||||
errorCode: 'claim_order_incomplete',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
}
|
||||
}
|
||||
|
||||
function assertTaskCanProceed(task) {
|
||||
if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) {
|
||||
throw createHttpError('当前任务已经结束,不能继续操作', {
|
||||
@@ -307,6 +378,21 @@ function assertTaskCanProceed(task) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertPublicClaimActionAllowed(task, action) {
|
||||
if (!isAssistedClaimTask(task)) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = action === 'confirm'
|
||||
? '当前商品需要客服复核角色,请登录后联系人工继续'
|
||||
: '当前商品需要客服确认后再执行兑换,请联系人工继续'
|
||||
|
||||
throw createHttpError(message, {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_support_review_required',
|
||||
})
|
||||
}
|
||||
|
||||
async function expireClaimContext(claimToken, task) {
|
||||
const now = nowIso()
|
||||
const nextClaimToken = await updateClaimToken(claimToken.id, {
|
||||
@@ -370,6 +456,16 @@ async function syncTaskWithSession(task, session) {
|
||||
patch.claimed_at = task.claimed_at || nowIso()
|
||||
}
|
||||
|
||||
if (session.review?.capturedAt) {
|
||||
patch.state_json = JSON.stringify({
|
||||
...parseTaskState(task),
|
||||
reviewScreenshotReady: true,
|
||||
reviewCapturedAt: String(session.review.capturedAt || ''),
|
||||
reviewRoleId: String(session.review.roleId || ''),
|
||||
reviewRoleName: String(session.review.roleName || ''),
|
||||
})
|
||||
}
|
||||
|
||||
if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) {
|
||||
patch.screenshot_path = task.screenshot_path || ''
|
||||
}
|
||||
@@ -389,6 +485,8 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
executorKey: task.executor_key || '',
|
||||
requiresSupportReview: isAssistedClaimTask(task),
|
||||
expiresAt: claimToken.expired_at,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
@@ -424,3 +522,25 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function parseTaskState(task) {
|
||||
const value = task?.state_json
|
||||
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function isAssistedClaimTask(task) {
|
||||
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ export async function createTencentBrowserSession(payload = {}) {
|
||||
qrUpdatedAt: '',
|
||||
lastState: null,
|
||||
lastRedeem: null,
|
||||
lastReview: null,
|
||||
lastError: '',
|
||||
presentationSyncAttempts: 0,
|
||||
}
|
||||
@@ -237,6 +238,11 @@ export async function refreshTencentBrowserSession(sessionId, { includeQrImage =
|
||||
activityInfo,
|
||||
loginType: session.loginType,
|
||||
})
|
||||
|
||||
if (credentialReady && activityInfo?.role?.ready) {
|
||||
await ensureReviewScreenshot(session, activityInfo)
|
||||
}
|
||||
|
||||
session.updatedAt = new Date().toISOString()
|
||||
session.expiresAt = Date.now() + SESSION_TTL_MS
|
||||
|
||||
@@ -335,6 +341,20 @@ export async function getTencentBrowserSessionScreenshotPath(sessionId) {
|
||||
return screenshotPath
|
||||
}
|
||||
|
||||
export async function getTencentBrowserSessionReviewScreenshotPath(sessionId) {
|
||||
const session = await getRequiredSession(sessionId)
|
||||
const screenshotPath = session.lastReview?.screenshotPath
|
||||
|
||||
if (!screenshotPath) {
|
||||
throw new SessionServiceError('当前会话还没有客服复核截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'review_screenshot_not_ready',
|
||||
})
|
||||
}
|
||||
|
||||
return screenshotPath
|
||||
}
|
||||
|
||||
async function ensureBrowser() {
|
||||
if (!browserPromise) {
|
||||
const launchOptions = resolveBrowserLaunchOptions()
|
||||
@@ -910,6 +930,7 @@ function buildSessionPayload(session, { includeQrImage = true } = {}) {
|
||||
qrUpdatedAt: session.qrUpdatedAt,
|
||||
credentialReady: Boolean(session.lastState?.credentialReady),
|
||||
activityInfo,
|
||||
review: buildReviewPayload(session.lastReview),
|
||||
redeem: buildRedeemPayload(session.lastRedeem),
|
||||
artifacts: buildArtifactsPayload(session),
|
||||
}
|
||||
@@ -928,11 +949,34 @@ function buildSessionPayload(session, { includeQrImage = true } = {}) {
|
||||
state: session.lastState,
|
||||
browserDebug: resolveBrowserLaunchOptions(),
|
||||
qrImagePath: session.qrImagePath,
|
||||
review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }),
|
||||
redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }),
|
||||
artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }),
|
||||
}
|
||||
}
|
||||
|
||||
function buildReviewPayload(review, { includeInternalPaths = false } = {}) {
|
||||
if (!review) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = {
|
||||
capturedAt: review.capturedAt,
|
||||
roleId: review.roleId || '',
|
||||
roleName: review.roleName || '',
|
||||
}
|
||||
|
||||
if (!includeInternalPaths) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
screenshotPath: review.screenshotPath || '',
|
||||
signature: review.signature || '',
|
||||
}
|
||||
}
|
||||
|
||||
function buildRedeemPayload(redeem, { includeInternalPaths = false } = {}) {
|
||||
if (!redeem) {
|
||||
return null
|
||||
@@ -972,6 +1016,7 @@ function buildRedeemPayload(redeem, { includeInternalPaths = false } = {}) {
|
||||
function buildArtifactsPayload(session, { includeInternalPaths = false } = {}) {
|
||||
const payload = {
|
||||
hasQrImage: Boolean(session.qrImageBase64),
|
||||
hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath),
|
||||
hasScreenshot: Boolean(session.lastRedeem?.screenshotPath),
|
||||
}
|
||||
|
||||
@@ -986,6 +1031,38 @@ function buildArtifactsPayload(session, { includeInternalPaths = false } = {}) {
|
||||
screenshotPath: session.lastRedeem?.screenshotPath || '',
|
||||
htmlPath: session.lastRedeem?.htmlPath || '',
|
||||
resultPath: session.lastRedeem?.resultPath || '',
|
||||
reviewScreenshotPath: session.lastReview?.screenshotPath || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureReviewScreenshot(session, activityInfo) {
|
||||
const roleId = String(activityInfo?.role?.roleId || '').trim()
|
||||
const roleName = String(activityInfo?.role?.roleName || '').trim()
|
||||
const signature = `${session.loginType}|${roleId}|${roleName}`
|
||||
|
||||
if (session.lastReview?.screenshotPath && session.lastReview?.signature === signature) {
|
||||
return
|
||||
}
|
||||
|
||||
const screenshotPath = path.join(session.sessionDir, 'role-review.png')
|
||||
|
||||
try {
|
||||
await session.page.screenshot({
|
||||
path: screenshotPath,
|
||||
fullPage: true,
|
||||
})
|
||||
session.lastReview = {
|
||||
screenshotPath,
|
||||
capturedAt: new Date().toISOString(),
|
||||
roleId,
|
||||
roleName,
|
||||
signature,
|
||||
}
|
||||
} catch (error) {
|
||||
logBrowserSessionDebug('reviewScreenshot.capture.error', {
|
||||
sessionId: session.sessionId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ function resolveTone(status: string) {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding'].includes(normalized)) {
|
||||
if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'support', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding'].includes(normalized)) {
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ function resolveLabel(status: string) {
|
||||
active: '生效中',
|
||||
admin: '管理员',
|
||||
operator: '普通运营',
|
||||
support: '客服',
|
||||
revoked: '已撤销',
|
||||
disabled: '已停用',
|
||||
success: '成功',
|
||||
|
||||
@@ -80,6 +80,10 @@ const emit = defineEmits<{
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="props.confirmReadonly && !props.roleConfirmed" class="confirm-box">
|
||||
当前商品需要客服复核,完成扫码登录后请联系人工继续。
|
||||
</div>
|
||||
|
||||
<div v-else class="confirm-box">
|
||||
<el-checkbox
|
||||
:model-value="props.roleConfirmed"
|
||||
|
||||
@@ -99,12 +99,23 @@ export function useClaimPage(token: string) {
|
||||
},
|
||||
])
|
||||
const canConfirmRole = computed(() =>
|
||||
Boolean(task.value && hasSession.value && roleReady.value && task.value.status === 'claimed'),
|
||||
Boolean(
|
||||
task.value
|
||||
&& !task.value.requiresSupportReview
|
||||
&& hasSession.value
|
||||
&& roleReady.value
|
||||
&& task.value.status === 'claimed',
|
||||
),
|
||||
)
|
||||
const canRedeem = computed(() =>
|
||||
Boolean(task.value && hasSession.value && roleConfirmed.value && !redeemLoading.value && (
|
||||
task.value.status === 'role_confirmed' || task.value.status === 'redeeming'
|
||||
)),
|
||||
Boolean(
|
||||
task.value
|
||||
&& !task.value.requiresSupportReview
|
||||
&& hasSession.value
|
||||
&& roleConfirmed.value
|
||||
&& !redeemLoading.value
|
||||
&& (task.value.status === 'role_confirmed' || task.value.status === 'redeeming'),
|
||||
),
|
||||
)
|
||||
const redeemBlockedReason = computed(() => {
|
||||
if (!detail.value) {
|
||||
@@ -119,6 +130,14 @@ export function useClaimPage(token: string) {
|
||||
return `请先选择${loginTypeLabel.value}并初始化登录会话`
|
||||
}
|
||||
|
||||
if (task.value?.requiresSupportReview) {
|
||||
if (!roleReady.value) {
|
||||
return '扫码成功后,系统会自动同步登录信息,准备发给客服复核'
|
||||
}
|
||||
|
||||
return '当前商品需要客服复核角色并代你发起兑换,请联系人工继续'
|
||||
}
|
||||
|
||||
if (redeemLoading.value) {
|
||||
return '兑换任务正在执行中'
|
||||
}
|
||||
@@ -133,7 +152,7 @@ export function useClaimPage(token: string) {
|
||||
|
||||
return ''
|
||||
})
|
||||
const redeemButtonLabel = computed(() => '开始兑换')
|
||||
const redeemButtonLabel = computed(() => (task.value?.requiresSupportReview ? '等待客服兑换' : '开始兑换'))
|
||||
const screenshotEmptyTitle = computed(() =>
|
||||
task.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图',
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { hasAdminSession } from '@/utils/admin-auth'
|
||||
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
@@ -42,6 +42,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
meta: { allowedRoles: ['admin'] },
|
||||
component: () => import('@/views/admin/AdminUsersView.vue'),
|
||||
},
|
||||
{
|
||||
@@ -66,6 +67,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: 'inventory',
|
||||
meta: { allowedRoles: ['admin', 'operator'] },
|
||||
component: () => import('@/views/admin/AdminInventoryView.vue'),
|
||||
},
|
||||
{
|
||||
@@ -74,22 +76,27 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: 'webhook-events',
|
||||
meta: { allowedRoles: ['admin', 'operator'] },
|
||||
component: () => import('@/views/admin/AdminWebhookEventsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'platform-shops',
|
||||
meta: { allowedRoles: ['admin'] },
|
||||
component: () => import('@/views/admin/AdminPlatformShopsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'platform-fulfillment',
|
||||
meta: { allowedRoles: ['admin'] },
|
||||
component: () => import('@/views/admin/AdminFulfillmentBindingsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'webhook-events/:eventId',
|
||||
meta: { allowedRoles: ['admin', 'operator'] },
|
||||
component: () => import('@/views/admin/AdminWebhookEventDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
meta: { allowedRoles: ['admin'] },
|
||||
component: () => import('@/views/admin/AdminAuditLogsView.vue'),
|
||||
},
|
||||
],
|
||||
@@ -118,6 +125,14 @@ router.beforeEach((to) => {
|
||||
return '/admin/login'
|
||||
}
|
||||
|
||||
const allowedRoles = to.matched
|
||||
.map((record) => (record.meta?.allowedRoles as string[] | undefined) || [])
|
||||
.find((roles) => Array.isArray(roles) && roles.length > 0)
|
||||
|
||||
if (allowedRoles && !allowedRoles.includes(getAdminRole())) {
|
||||
return '/admin/dashboard'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -131,6 +131,14 @@ export function regenerateAdminTaskClaimLink(taskId: number | string) {
|
||||
)
|
||||
}
|
||||
|
||||
export function confirmAdminTaskAssistedRole(taskId: number | string) {
|
||||
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/support-confirm-role`, {})
|
||||
}
|
||||
|
||||
export function redeemAdminTaskAssisted(taskId: number | string) {
|
||||
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/support-redeem`, {})
|
||||
}
|
||||
|
||||
export function closeAdminTask(taskId: number | string) {
|
||||
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/close`, {})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface AdminPagination {
|
||||
total: number
|
||||
}
|
||||
|
||||
export type AdminRole = 'admin' | 'operator'
|
||||
export type AdminRole = 'admin' | 'operator' | 'support'
|
||||
export type AdminUserStatus = 'active' | 'disabled'
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
@@ -199,6 +199,9 @@ export interface AdminTaskOperations {
|
||||
canClose: boolean
|
||||
canMarkManualReview: boolean
|
||||
canCompleteManualDispatch: boolean
|
||||
canSupportConfirmRole: boolean
|
||||
canSupportRedeem: boolean
|
||||
canViewSensitiveTaskData: boolean
|
||||
}
|
||||
|
||||
export interface AdminTaskActionResponse {
|
||||
@@ -301,6 +304,7 @@ export interface AdminTaskDetail {
|
||||
token: string
|
||||
status: string
|
||||
expiredAt: string
|
||||
claimUrl: string
|
||||
}
|
||||
inventory: null | {
|
||||
inventoryItemId: number
|
||||
@@ -332,6 +336,12 @@ export interface AdminTaskDetail {
|
||||
}>
|
||||
artifacts: Record<string, unknown>
|
||||
screenshotUrl: string
|
||||
review: {
|
||||
required: boolean
|
||||
screenshotCapturedAt: string | null
|
||||
roleId: string
|
||||
roleName: string
|
||||
}
|
||||
manualDispatch: null | {
|
||||
outcome: string
|
||||
deliveryReference: string
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ClaimTaskInfo {
|
||||
taskId: number
|
||||
taskNo: string
|
||||
status: ClaimTaskStatus
|
||||
executorKey: string
|
||||
requiresSupportReview: boolean
|
||||
expiresAt: string | null
|
||||
claimedAt: string | null
|
||||
roleConfirmedAt: string | null
|
||||
|
||||
@@ -4,6 +4,12 @@ const ADMIN_USER_ID_KEY = 'order-site-admin-user-id'
|
||||
const ADMIN_USERNAME_KEY = 'order-site-admin-username'
|
||||
const ADMIN_ROLE_KEY = 'order-site-admin-role'
|
||||
|
||||
const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin', number> = {
|
||||
support: 1,
|
||||
operator: 2,
|
||||
admin: 3,
|
||||
}
|
||||
|
||||
export function getAdminToken() {
|
||||
return localStorage.getItem(ADMIN_TOKEN_KEY) || ''
|
||||
}
|
||||
@@ -23,21 +29,25 @@ export function getAdminUsername() {
|
||||
|
||||
export function getAdminRole() {
|
||||
const role = localStorage.getItem(ADMIN_ROLE_KEY)
|
||||
return role === 'admin' ? 'admin' : 'operator'
|
||||
}
|
||||
|
||||
export function hasAdminRole(role: 'admin' | 'operator') {
|
||||
if (role === 'operator') {
|
||||
return Boolean(getAdminToken())
|
||||
if (role === 'admin' || role === 'operator' || role === 'support') {
|
||||
return role
|
||||
}
|
||||
|
||||
return getAdminRole() === 'admin'
|
||||
return 'operator'
|
||||
}
|
||||
|
||||
export function hasAdminRole(role: 'admin' | 'operator' | 'support') {
|
||||
if (!getAdminToken()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ADMIN_ROLE_LEVEL[getAdminRole()] >= ADMIN_ROLE_LEVEL[role]
|
||||
}
|
||||
|
||||
export function setAdminSession(
|
||||
token: string,
|
||||
expiresAt: string,
|
||||
user?: { userId: number; username: string; role: 'admin' | 'operator' },
|
||||
user?: { userId: number; username: string; role: 'admin' | 'operator' | 'support' },
|
||||
) {
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, token)
|
||||
localStorage.setItem(ADMIN_EXPIRES_AT_KEY, expiresAt)
|
||||
|
||||
@@ -57,6 +57,7 @@ export const adminUserRoleOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '管理员', value: 'admin' },
|
||||
{ label: '普通运营', value: 'operator' },
|
||||
{ label: '客服', value: 'support' },
|
||||
]
|
||||
|
||||
export const adminUserStatusOptions = [
|
||||
|
||||
@@ -55,6 +55,7 @@ type ValidationState = {
|
||||
|
||||
const PROFILE_OPTIONS = [
|
||||
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
|
||||
{ label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' },
|
||||
{ label: '人工发货', value: 'manual_review' },
|
||||
]
|
||||
|
||||
@@ -405,7 +406,7 @@ onMounted(loadConfigs)
|
||||
</div>
|
||||
<div class="meta-line">
|
||||
<span class="meta-label">说明</span>
|
||||
<span>每条规则同时定义“外部商品匹配条件”和“内部履约 SKU / 履约方式”。未命中的商品会进入人工处理。</span>
|
||||
<span>每条规则同时定义“外部商品匹配条件”和“内部履约 SKU / 履约方式”。未命中的商品会直接忽略,不再继续走后续履约动作。</span>
|
||||
</div>
|
||||
<div class="meta-line">
|
||||
<span class="meta-label">字段怎么理解</span>
|
||||
@@ -424,7 +425,7 @@ onMounted(loadConfigs)
|
||||
</div>
|
||||
<div class="hint-item">
|
||||
<strong>履约方式</strong>
|
||||
<span>`腾讯领取兑换` 表示走自动库存链路,`人工发货` 表示落到人工处理。</span>
|
||||
<span>`腾讯领取兑换` 表示用户确认后自动兑换,`腾讯领取兑换(半自动+人工)` 表示用户先登录、客服确认角色后再自动兑换,`人工发货` 表示直接落到人工处理。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -423,12 +423,12 @@ onMounted(loadInventoryItems)
|
||||
<article class="guide-item">
|
||||
<span class="guide-label">2. 库存预占</span>
|
||||
<h2>再按凭据类型找库存项</h2>
|
||||
<p>`tencent_claim_redeem` 当前会优先查找 `tencent_code`,适合三角洲行动这类腾讯兑换码自动领取场景。以后扩展别的履约档案时,也会按各自 requirement 匹配。</p>
|
||||
<p>`tencent_claim_redeem` 和 `tencent_claim_assisted` 当前都会优先查找 `tencent_code`,适合三角洲行动这类腾讯兑换码领取场景。以后扩展别的履约档案时,也会按各自 requirement 匹配。</p>
|
||||
</article>
|
||||
<article class="guide-item">
|
||||
<span class="guide-label">3. 履约结果</span>
|
||||
<h2>自动绑定或转人工</h2>
|
||||
<p>命中库存后任务会进入预占 / 发放链路;没命中库存、或配置走 `manual_review` 时,会落到人工处理。</p>
|
||||
<p>命中库存后任务会进入预占 / 发放链路;配置为 `tencent_claim_assisted` 时会进入“用户登录 + 客服确认 + 后端自动兑换”;配置走 `manual_review` 时,会落到人工处理。</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -8,17 +8,33 @@ import { clearAdminSession, getAdminRole, getAdminTokenExpiresAt, getAdminUserna
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const router = useRouter()
|
||||
const isAdmin = computed(() => getAdminRole() === 'admin')
|
||||
const currentRole = computed(() => getAdminRole())
|
||||
const isAdmin = computed(() => currentRole.value === 'admin')
|
||||
const isOperator = computed(() => currentRole.value === 'operator')
|
||||
const roleLabel = computed(() => {
|
||||
if (currentRole.value === 'admin') {
|
||||
return '管理员'
|
||||
}
|
||||
|
||||
if (currentRole.value === 'support') {
|
||||
return '客服'
|
||||
}
|
||||
|
||||
return '普通运营'
|
||||
})
|
||||
const navItems = computed(() => {
|
||||
const baseItems = [
|
||||
{ to: '/admin/dashboard', label: '概览' },
|
||||
{ to: '/admin/orders', label: '订单' },
|
||||
{ to: '/admin/tasks', label: '任务' },
|
||||
{ to: '/admin/inventory', label: '库存' },
|
||||
{ to: '/admin/message-deliveries', label: '消息发送' },
|
||||
{ to: '/admin/webhook-events', label: 'Webhook' },
|
||||
]
|
||||
|
||||
if (isAdmin.value || isOperator.value) {
|
||||
baseItems.splice(3, 0, { to: '/admin/inventory', label: '库存' })
|
||||
baseItems.push({ to: '/admin/webhook-events', label: 'Webhook' })
|
||||
}
|
||||
|
||||
if (isAdmin.value) {
|
||||
baseItems.splice(1, 0, { to: '/admin/users', label: '用户' })
|
||||
baseItems.push({ to: '/admin/platform-shops', label: 'Agiso店铺' })
|
||||
@@ -54,7 +70,7 @@ async function submitLogout() {
|
||||
<span>当前账号</span>
|
||||
<strong>{{ getAdminUsername() || '未读取' }}</strong>
|
||||
<span>当前角色</span>
|
||||
<strong>{{ getAdminRole() === 'admin' ? '管理员' : '普通运营' }}</strong>
|
||||
<strong>{{ roleLabel }}</strong>
|
||||
<span>登录有效期</span>
|
||||
<strong>{{ formatAdminDateTime(getAdminTokenExpiresAt()) }}</strong>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
closeAdminTask,
|
||||
confirmAdminTaskAssistedRole,
|
||||
completeAdminTaskManualDispatch,
|
||||
redeemAdminTaskAssisted,
|
||||
fetchAdminTaskScreenshot,
|
||||
fetchAdminTaskDetail,
|
||||
markAdminTaskManualReview,
|
||||
@@ -31,6 +33,16 @@ const manualDispatchForm = reactive({
|
||||
deliveredCredential: '',
|
||||
resultMessage: '',
|
||||
})
|
||||
const canOperateTasks = computed(() => hasAdminRole('operator'))
|
||||
const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData))
|
||||
const claimUrl = computed(() => lastClaimUrl.value || detail.value?.claimToken?.claimUrl || '')
|
||||
const screenshotSectionTitle = computed(() => {
|
||||
if (detail.value?.task.status === 'redeemed') {
|
||||
return '结果截图'
|
||||
}
|
||||
|
||||
return detail.value?.review.required ? '客服复核截图' : '结果截图'
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
@@ -168,7 +180,10 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
<p>系统绑定:<AdminStatusTag :status="detail.task.systemBindingStatus" /> · 完整绑定:<AdminStatusTag :status="detail.task.userBindingStatus" /></p>
|
||||
<p>角色:{{ detail.task.roleName || '-' }} / {{ detail.task.roleId || '-' }}</p>
|
||||
<p>最后错误:{{ detail.task.lastError || '-' }}</p>
|
||||
<p v-if="lastClaimUrl">新领取链接:{{ lastClaimUrl }}</p>
|
||||
<p v-if="claimUrl">
|
||||
领取链接:
|
||||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="action-row">
|
||||
@@ -182,7 +197,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
重试任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin')"
|
||||
v-if="detail.operations.canRegenerateClaimLink"
|
||||
:disabled="!detail.operations.canRegenerateClaimLink"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@@ -191,7 +206,33 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
重发链接
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin')"
|
||||
v-if="detail.operations.canSupportConfirmRole"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="warning"
|
||||
@click="runAction(
|
||||
() => confirmAdminTaskAssistedRole(detail!.task.taskId),
|
||||
'角色已确认',
|
||||
`确认以客服身份锁定任务 ${detail!.task.taskNo} 当前识别到的角色信息吗?`
|
||||
)"
|
||||
>
|
||||
客服确认角色
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canSupportRedeem"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="runAction(
|
||||
() => redeemAdminTaskAssisted(detail!.task.taskId),
|
||||
'兑换任务已启动',
|
||||
`确认开始执行任务 ${detail!.task.taskNo} 的自动兑换吗?`
|
||||
)"
|
||||
>
|
||||
客服开始兑换
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canOperateTasks"
|
||||
:disabled="!detail.operations.canMarkManualReview"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@@ -200,7 +241,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
转人工
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin') && detail.operations.canCompleteManualDispatch"
|
||||
v-if="canOperateTasks && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@@ -209,7 +250,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
人工完成
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin') && detail.operations.canCompleteManualDispatch"
|
||||
v-if="canOperateTasks && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
@@ -219,7 +260,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
履约失败
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAdminRole('admin')"
|
||||
v-if="canOperateTasks"
|
||||
:disabled="!detail.operations.canClose"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@@ -240,12 +281,20 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
<tr><th>履约状态</th><td><AdminStatusTag :status="detail.task.deliveryStatus || ''" /></td></tr>
|
||||
<tr><th>结果代码</th><td>{{ detail.task.resultCode || '-' }}</td></tr>
|
||||
<tr><th>结果说明</th><td>{{ detail.task.resultMessage || '-' }}</td></tr>
|
||||
<tr><th>库存凭据</th><td>{{ detail.inventory?.displayValue || '-' }}</td></tr>
|
||||
<tr><th>凭据类型</th><td>{{ detail.inventory?.credentialType || '-' }}</td></tr>
|
||||
<tr><th>库存状态</th><td><AdminStatusTag :status="detail.inventory?.status || ''" /></td></tr>
|
||||
<tr><th>Claim Token</th><td>{{ detail.claimToken?.token || '-' }}</td></tr>
|
||||
<tr v-if="canViewSensitiveTaskData"><th>库存凭据</th><td>{{ detail.inventory?.displayValue || '-' }}</td></tr>
|
||||
<tr v-if="canViewSensitiveTaskData"><th>凭据类型</th><td>{{ detail.inventory?.credentialType || '-' }}</td></tr>
|
||||
<tr v-if="canViewSensitiveTaskData"><th>库存状态</th><td><AdminStatusTag :status="detail.inventory?.status || ''" /></td></tr>
|
||||
<tr v-if="canViewSensitiveTaskData"><th>Claim Token</th><td>{{ detail.claimToken?.token || '-' }}</td></tr>
|
||||
<tr v-else><th>库存 / Token</th><td>客服账号不可查看库存凭据与原始领取 Token</td></tr>
|
||||
<tr><th>Token 状态</th><td><AdminStatusTag :status="detail.claimToken?.status || ''" /></td></tr>
|
||||
<tr><th>Token 过期</th><td>{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}</td></tr>
|
||||
<tr>
|
||||
<th>领取链接</th>
|
||||
<td>
|
||||
<a v-if="claimUrl" class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>浏览器会话</th><td>{{ detail.task.browserSessionId || '-' }}</td></tr>
|
||||
<tr><th>用户打开链接</th><td>{{ formatAdminDateTime(detail.task.claimedAt) }}</td></tr>
|
||||
<tr><th>用户提交绑定</th><td>{{ formatAdminDateTime(detail.task.roleConfirmedAt) }}</td></tr>
|
||||
@@ -295,7 +344,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
</div>
|
||||
<div v-if="binding.invalidReason" class="binding-meta">作废原因:{{ binding.invalidReason }}</div>
|
||||
</td>
|
||||
<td>{{ binding.displayValue || '-' }}</td>
|
||||
<td>{{ canViewSensitiveTaskData ? (binding.displayValue || '-') : '客服不可见' }}</td>
|
||||
<td>
|
||||
<div>绑定 {{ formatAdminDateTime(binding.createdAt) }}</div>
|
||||
<div class="binding-meta">消费 {{ formatAdminDateTime(binding.consumedAt) }}</div>
|
||||
@@ -328,6 +377,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
<span>外部流水 / 单号</span>
|
||||
<el-input
|
||||
v-model="manualDispatchForm.deliveryReference"
|
||||
:disabled="!canOperateTasks"
|
||||
maxlength="120"
|
||||
placeholder="例如快递单号、平台消息回执号"
|
||||
/>
|
||||
@@ -336,6 +386,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
<span>已交付内容</span>
|
||||
<el-input
|
||||
v-model="manualDispatchForm.deliveredCredential"
|
||||
:disabled="!canOperateTasks"
|
||||
maxlength="500"
|
||||
placeholder="可填写人工发送的卡密、链接或关键信息"
|
||||
type="textarea"
|
||||
@@ -346,6 +397,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
<span>处理备注</span>
|
||||
<el-input
|
||||
v-model="manualDispatchForm.resultMessage"
|
||||
:disabled="!canOperateTasks"
|
||||
maxlength="500"
|
||||
placeholder="说明实际履约结果,失败时建议写清原因"
|
||||
type="textarea"
|
||||
@@ -379,10 +431,10 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
</section>
|
||||
|
||||
<section v-if="detail.screenshotUrl" class="table-card">
|
||||
<h3>结果截图</h3>
|
||||
<h3>{{ screenshotSectionTitle }}</h3>
|
||||
<p v-if="!screenshotPreviewUrl" class="screenshot-empty">截图加载中或当前不可读取。</p>
|
||||
<a v-else class="screenshot-link" :href="screenshotPreviewUrl" target="_blank" rel="noreferrer">打开原图</a>
|
||||
<img v-if="screenshotPreviewUrl" class="screenshot-preview" :src="screenshotPreviewUrl" alt="任务兑换截图" />
|
||||
<img v-if="screenshotPreviewUrl" class="screenshot-preview" :src="screenshotPreviewUrl" :alt="screenshotSectionTitle" />
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
@@ -392,6 +444,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
||||
.admin-panel { display: grid; gap: 16px; }
|
||||
.panel-header h1 { margin: 0; color: #1d3555; }
|
||||
.panel-header p { margin: 8px 0 0; color: #64748b; }
|
||||
.claim-link { color: #175cd3; text-decoration: none; word-break: break-all; }
|
||||
.info-card,.table-card,.empty-block,.error-copy {
|
||||
padding: 18px; border-radius: 20px; background: rgba(255,255,255,.94); border: 1px solid rgba(86,108,138,.1);
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
|
||||
重试
|
||||
</el-button>
|
||||
<div
|
||||
v-if="hasAdminRole('admin')"
|
||||
v-if="hasAdminRole('operator')"
|
||||
class="more-action-wrap"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
@@ -37,6 +37,18 @@ function isCurrentUser(userId: number) {
|
||||
return getAdminUserId() === userId
|
||||
}
|
||||
|
||||
function formatRoleLabel(role: AdminRole) {
|
||||
if (role === 'admin') {
|
||||
return '管理员'
|
||||
}
|
||||
|
||||
if (role === 'support') {
|
||||
return '客服'
|
||||
}
|
||||
|
||||
return '普通运营'
|
||||
}
|
||||
|
||||
async function loadUsers(page = pagination.value.page) {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
@@ -90,11 +102,13 @@ async function submitCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRole(item: AdminUserListItem) {
|
||||
const nextRole: AdminRole = item.role === 'admin' ? 'operator' : 'admin'
|
||||
async function updateRole(item: AdminUserListItem, nextRole: AdminRole) {
|
||||
if (item.role === nextRole) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await showConfirm(`确认将 ${item.username} 调整为${nextRole === 'admin' ? '管理员' : '普通运营'}吗?`, '确认操作', {
|
||||
await showConfirm(`确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`, '确认操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
@@ -202,6 +216,7 @@ onMounted(loadUsers)
|
||||
<input v-model="createPassword" class="text-input" type="password" placeholder="新密码,至少 8 位" />
|
||||
<select v-model="createRole" class="text-input select-input">
|
||||
<option value="operator">普通运营</option>
|
||||
<option value="support">客服</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
<el-button :loading="creating" round type="primary" @click="submitCreate">新增用户</el-button>
|
||||
@@ -228,7 +243,7 @@ onMounted(loadUsers)
|
||||
<tr v-for="item in items" :key="item.userId">
|
||||
<td>{{ item.userId }}</td>
|
||||
<td>{{ item.username }}</td>
|
||||
<td>{{ item.role === 'admin' ? '管理员' : '普通运营' }}</td>
|
||||
<td>{{ formatRoleLabel(item.role) }}</td>
|
||||
<td><AdminStatusTag :status="item.status" /></td>
|
||||
<td>{{ formatAdminDateTime(item.createdAt) }}</td>
|
||||
<td>{{ formatAdminDateTime(item.updatedAt) }}</td>
|
||||
@@ -238,9 +253,25 @@ onMounted(loadUsers)
|
||||
:loading="actionLoadingId === item.userId"
|
||||
link
|
||||
:disabled="isCurrentUser(item.userId)"
|
||||
@click="toggleRole(item)"
|
||||
@click="updateRole(item, 'admin')"
|
||||
>
|
||||
{{ item.role === 'admin' ? '设为运营' : '设为管理员' }}
|
||||
设为管理员
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="actionLoadingId === item.userId"
|
||||
link
|
||||
:disabled="isCurrentUser(item.userId)"
|
||||
@click="updateRole(item, 'operator')"
|
||||
>
|
||||
设为运营
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="actionLoadingId === item.userId"
|
||||
link
|
||||
:disabled="isCurrentUser(item.userId)"
|
||||
@click="updateRole(item, 'support')"
|
||||
>
|
||||
设为客服
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="actionLoadingId === item.userId"
|
||||
|
||||
@@ -62,6 +62,10 @@ const helperText = computed(() => {
|
||||
return '正在加载领取任务'
|
||||
}
|
||||
|
||||
if (task.value.requiresSupportReview) {
|
||||
return `订单 ${order.value?.platformOrderId || '-'} · 登录后请联系人工客服继续确认和兑换`
|
||||
}
|
||||
|
||||
return `订单 ${order.value?.platformOrderId || '-'} · 任务 ${task.value.taskNo}`
|
||||
})
|
||||
</script>
|
||||
@@ -135,7 +139,7 @@ const helperText = computed(() => {
|
||||
:can-confirm-role="canConfirmRole"
|
||||
confirm-action-label="确认当前角色并继续"
|
||||
:confirm-action-loading="roleConfirmLoading"
|
||||
:confirm-action-visible="!roleConfirmed"
|
||||
:confirm-action-visible="!roleConfirmed && !task?.requiresSupportReview"
|
||||
:confirm-readonly="true"
|
||||
:hide-redeem-form="true"
|
||||
:login-type-label="loginTypeLabel"
|
||||
|
||||
Reference in New Issue
Block a user