优化客服权限-增加兑换截图和关闭任务
This commit is contained in:
@@ -198,7 +198,7 @@ router.post('/tasks/:taskId/support-redeem', requireAdminRoles(['admin', 'operat
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
router.post('/tasks/:taskId/close', requireAdminRoles(['admin', 'operator']), createJsonHandler(
|
router.post('/tasks/:taskId/close', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler(
|
||||||
(req) => closeAdminTask((/** @type {AdminTaskRouteParams} */ (req.params)).taskId),
|
(req) => closeAdminTask((/** @type {AdminTaskRouteParams} */ (req.params)).taskId),
|
||||||
{
|
{
|
||||||
successMessage: '任务已关闭',
|
successMessage: '任务已关闭',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
|||||||
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
|
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
|
||||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||||
import {
|
import {
|
||||||
|
canViewerCloseTask,
|
||||||
canRegenerateClaimLinkForViewer,
|
canRegenerateClaimLinkForViewer,
|
||||||
canViewerConfirmAssistedRole,
|
canViewerConfirmAssistedRole,
|
||||||
canViewerRedeemAssistedTask,
|
canViewerRedeemAssistedTask,
|
||||||
@@ -281,7 +282,7 @@ export async function getAdminTaskDetail(taskId, session = null) {
|
|||||||
&& ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
&& ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
||||||
canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease),
|
canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease),
|
||||||
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
|
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
|
||||||
canClose: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed'].includes(task.task_status),
|
canClose: canViewerCloseTask(task, viewerContext),
|
||||||
canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].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),
|
canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status),
|
||||||
canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext),
|
canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext),
|
||||||
@@ -295,18 +296,6 @@ export async function getAdminTaskDetail(taskId, session = null) {
|
|||||||
/** @param {AdminViewerSessionInput | null} [session] */
|
/** @param {AdminViewerSessionInput | null} [session] */
|
||||||
export async function getAdminTaskScreenshotPath(taskId, session = null) {
|
export async function getAdminTaskScreenshotPath(taskId, session = null) {
|
||||||
const task = await getRequiredTask(taskId)
|
const task = await getRequiredTask(taskId)
|
||||||
const viewerContext = createAdminViewerContext(session)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.screenshot_path) {
|
if (task.screenshot_path) {
|
||||||
return task.screenshot_path
|
return task.screenshot_path
|
||||||
@@ -316,7 +305,7 @@ export async function getAdminTaskScreenshotPath(taskId, session = null) {
|
|||||||
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw createHttpError('当前任务还没有截图', {
|
throw createHttpError('当前任务还没有可查看截图', {
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
errorCode: 'admin_task_screenshot_not_found',
|
errorCode: 'admin_task_screenshot_not_found',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -68,10 +68,6 @@ export function createAdminViewerContext(session = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveAdminTaskScreenshotUrl(task, viewerContext) {
|
export 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) {
|
if (task.screenshot_path || task.browser_session_id) {
|
||||||
return `/api/v1/admin/tasks/${task.id}/screenshot`
|
return `/api/v1/admin/tasks/${task.id}/screenshot`
|
||||||
}
|
}
|
||||||
@@ -79,6 +75,14 @@ export async function resolveAdminTaskScreenshotUrl(task, viewerContext) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canViewerCloseTask(task, viewerContext) {
|
||||||
|
if (!['admin', 'operator', 'support'].includes(viewerContext.role)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !['redeemed', 'closed'].includes(String(task?.task_status || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveDisplayShopName(provider, shopId, shopName) {
|
export function resolveDisplayShopName(provider, shopId, shopName) {
|
||||||
const normalizedShopName = String(shopName || '').trim()
|
const normalizedShopName = String(shopName || '').trim()
|
||||||
if (normalizedShopName) {
|
if (normalizedShopName) {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
canViewerCloseTask,
|
||||||
|
createAdminViewerContext,
|
||||||
|
resolveAdminTaskScreenshotUrl,
|
||||||
|
} from './admin-read-shared-helpers.js'
|
||||||
|
|
||||||
|
test('resolveAdminTaskScreenshotUrl lets support view final redeemed screenshot', async () => {
|
||||||
|
const task = {
|
||||||
|
id: 12,
|
||||||
|
screenshot_path: '/tmp/redeemed-12.png',
|
||||||
|
browser_session_id: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' }))
|
||||||
|
|
||||||
|
assert.equal(screenshotUrl, '/api/v1/admin/tasks/12/screenshot')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveAdminTaskScreenshotUrl falls back to review screenshot when only browser session exists', async () => {
|
||||||
|
const task = {
|
||||||
|
id: 13,
|
||||||
|
screenshot_path: '',
|
||||||
|
browser_session_id: 'browser-session-13',
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' }))
|
||||||
|
|
||||||
|
assert.equal(screenshotUrl, '/api/v1/admin/tasks/13/screenshot')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canViewerCloseTask allows support to close active task but not redeemed task', () => {
|
||||||
|
const viewerContext = createAdminViewerContext({ role: 'support' })
|
||||||
|
|
||||||
|
assert.equal(canViewerCloseTask({ task_status: 'link_generated' }, viewerContext), true)
|
||||||
|
assert.equal(canViewerCloseTask({ task_status: 'redeemed' }, viewerContext), false)
|
||||||
|
})
|
||||||
@@ -33,7 +33,8 @@ const manualDispatchForm = reactive({
|
|||||||
deliveredCredential: '',
|
deliveredCredential: '',
|
||||||
resultMessage: '',
|
resultMessage: '',
|
||||||
})
|
})
|
||||||
const canOperateTasks = computed(() => hasAdminRole('operator'))
|
const canManageTaskLifecycle = computed(() => hasAdminRole('operator'))
|
||||||
|
const canCloseTasks = computed(() => hasAdminRole('support'))
|
||||||
const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData))
|
const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData))
|
||||||
const claimUrl = computed(() => {
|
const claimUrl = computed(() => {
|
||||||
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
|
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
|
||||||
@@ -304,6 +305,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
|
|
||||||
<section class="action-row">
|
<section class="action-row">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canManageTaskLifecycle"
|
||||||
:disabled="!detail.operations.canRetry"
|
:disabled="!detail.operations.canRetry"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
round
|
round
|
||||||
@@ -348,7 +350,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
客服开始兑换
|
客服开始兑换
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canOperateTasks"
|
v-if="canManageTaskLifecycle"
|
||||||
:disabled="!detail.operations.canMarkManualReview"
|
:disabled="!detail.operations.canMarkManualReview"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
round
|
round
|
||||||
@@ -357,7 +359,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
转人工
|
转人工
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canOperateTasks && detail.operations.canCompleteManualDispatch"
|
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
round
|
round
|
||||||
type="success"
|
type="success"
|
||||||
@@ -366,7 +368,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
人工完成
|
人工完成
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canOperateTasks && detail.operations.canCompleteManualDispatch"
|
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
round
|
round
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -376,7 +378,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
履约失败
|
履约失败
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canOperateTasks"
|
v-if="canCloseTasks"
|
||||||
:disabled="!detail.operations.canClose"
|
:disabled="!detail.operations.canClose"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
round
|
round
|
||||||
@@ -590,7 +592,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
<span>外部流水 / 单号</span>
|
<span>外部流水 / 单号</span>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="manualDispatchForm.deliveryReference"
|
v-model="manualDispatchForm.deliveryReference"
|
||||||
:disabled="!canOperateTasks"
|
:disabled="!canManageTaskLifecycle"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
placeholder="例如快递单号、平台消息回执号"
|
placeholder="例如快递单号、平台消息回执号"
|
||||||
/>
|
/>
|
||||||
@@ -599,7 +601,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
<span>已交付内容</span>
|
<span>已交付内容</span>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="manualDispatchForm.deliveredCredential"
|
v-model="manualDispatchForm.deliveredCredential"
|
||||||
:disabled="!canOperateTasks"
|
:disabled="!canManageTaskLifecycle"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
placeholder="可填写人工发送的卡密、链接或关键信息"
|
placeholder="可填写人工发送的卡密、链接或关键信息"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -610,7 +612,7 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
<span>处理备注</span>
|
<span>处理备注</span>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="manualDispatchForm.resultMessage"
|
v-model="manualDispatchForm.resultMessage"
|
||||||
:disabled="!canOperateTasks"
|
:disabled="!canManageTaskLifecycle"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
placeholder="说明实际履约结果,失败时建议写清原因"
|
placeholder="说明实际履约结果,失败时建议写清原因"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const actionLoadingId = ref<number | null>(null)
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const status = ref('')
|
const status = ref('')
|
||||||
const taskNo = ref('')
|
const taskNo = ref('')
|
||||||
|
const platformOrderId = ref('')
|
||||||
const roleId = ref('')
|
const roleId = ref('')
|
||||||
const skuCode = ref('')
|
const skuCode = ref('')
|
||||||
const dateFrom = ref('')
|
const dateFrom = ref('')
|
||||||
@@ -32,6 +33,8 @@ const pagination = ref<AdminPagination>({
|
|||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
total: 0,
|
total: 0,
|
||||||
})
|
})
|
||||||
|
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||||
|
const canCloseTasks = hasAdminRole('support')
|
||||||
|
|
||||||
type AdminTaskActionKey = 'retry' | 'regenerate_claim_link' | 'manual_review' | 'close'
|
type AdminTaskActionKey = 'retry' | 'regenerate_claim_link' | 'manual_review' | 'close'
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ async function loadTasks(page = pagination.value.page) {
|
|||||||
pageSize: pagination.value.pageSize,
|
pageSize: pagination.value.pageSize,
|
||||||
status: status.value.trim(),
|
status: status.value.trim(),
|
||||||
taskNo: taskNo.value.trim(),
|
taskNo: taskNo.value.trim(),
|
||||||
|
platformOrderId: platformOrderId.value.trim(),
|
||||||
roleId: roleId.value.trim(),
|
roleId: roleId.value.trim(),
|
||||||
skuCode: skuCode.value.trim(),
|
skuCode: skuCode.value.trim(),
|
||||||
dateFrom: dateFrom.value.trim(),
|
dateFrom: dateFrom.value.trim(),
|
||||||
@@ -188,6 +192,7 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<input v-model="taskNo" class="text-input" placeholder="任务号" />
|
<input v-model="taskNo" class="text-input" placeholder="任务号" />
|
||||||
|
<input v-model="platformOrderId" class="text-input" placeholder="订单号" />
|
||||||
<input v-model="roleId" class="text-input" placeholder="角色 ID" />
|
<input v-model="roleId" class="text-input" placeholder="角色 ID" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -195,7 +200,7 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
|
|||||||
<input v-model="skuCode" class="text-input" placeholder="SKU" />
|
<input v-model="skuCode" class="text-input" placeholder="SKU" />
|
||||||
<input v-model="dateFrom" class="text-input" type="date" placeholder="开始日期" />
|
<input v-model="dateFrom" class="text-input" type="date" placeholder="开始日期" />
|
||||||
<input v-model="dateTo" class="text-input" type="date" placeholder="结束日期" />
|
<input v-model="dateTo" class="text-input" type="date" placeholder="结束日期" />
|
||||||
<el-button round type="primary" @click="() => loadTasks()">查询</el-button>
|
<el-button round type="primary" @click="() => loadTasks(1)">查询</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||||||
@@ -277,6 +282,7 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
|
|||||||
<td>
|
<td>
|
||||||
<div class="action-cell">
|
<div class="action-cell">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="canManageTaskLifecycle"
|
||||||
:disabled="item.executorKey === 'manual_dispatch' || !['retry_pending', 'manual_review', 'waiting_inventory'].includes(item.status)"
|
:disabled="item.executorKey === 'manual_dispatch' || !['retry_pending', 'manual_review', 'waiting_inventory'].includes(item.status)"
|
||||||
:loading="actionLoadingId === item.taskId"
|
:loading="actionLoadingId === item.taskId"
|
||||||
class="primary-action"
|
class="primary-action"
|
||||||
@@ -287,7 +293,7 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
|
|||||||
重试
|
重试
|
||||||
</el-button>
|
</el-button>
|
||||||
<div
|
<div
|
||||||
v-if="hasAdminRole('operator')"
|
v-if="canCloseTasks"
|
||||||
class="more-action-wrap"
|
class="more-action-wrap"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
@@ -297,17 +303,27 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
|
|||||||
</el-button>
|
</el-button>
|
||||||
<div v-if="openedActionTaskId === item.taskId" class="more-action-menu">
|
<div v-if="openedActionTaskId === item.taskId" class="more-action-menu">
|
||||||
<button
|
<button
|
||||||
v-if="item.executorKey !== 'manual_dispatch'"
|
v-if="canManageTaskLifecycle && item.executorKey !== 'manual_dispatch'"
|
||||||
class="menu-action-button"
|
class="menu-action-button"
|
||||||
type="button"
|
type="button"
|
||||||
@click="handleActionCommand(`regenerate_claim_link:${item.taskId}`)"
|
@click="handleActionCommand(`regenerate_claim_link:${item.taskId}`)"
|
||||||
>
|
>
|
||||||
重发链接
|
重发链接
|
||||||
</button>
|
</button>
|
||||||
<button class="menu-action-button" type="button" @click="handleActionCommand(`manual_review:${item.taskId}`)">
|
<button
|
||||||
|
v-if="canManageTaskLifecycle"
|
||||||
|
class="menu-action-button"
|
||||||
|
type="button"
|
||||||
|
@click="handleActionCommand(`manual_review:${item.taskId}`)"
|
||||||
|
>
|
||||||
转人工
|
转人工
|
||||||
</button>
|
</button>
|
||||||
<button class="menu-action-button menu-action-button-danger" type="button" @click="handleActionCommand(`close:${item.taskId}`)">
|
<button
|
||||||
|
v-if="!['redeemed', 'closed'].includes(item.status)"
|
||||||
|
class="menu-action-button menu-action-button-danger"
|
||||||
|
type="button"
|
||||||
|
@click="handleActionCommand(`close:${item.taskId}`)"
|
||||||
|
>
|
||||||
关闭任务
|
关闭任务
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user