diff --git a/apps/backend/src/routes/admin/tasks.js b/apps/backend/src/routes/admin/tasks.js
index 72ea5101..5d46c032 100644
--- a/apps/backend/src/routes/admin/tasks.js
+++ b/apps/backend/src/routes/admin/tasks.js
@@ -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),
{
successMessage: '任务已关闭',
diff --git a/apps/backend/src/services/admin/admin-read-service.js b/apps/backend/src/services/admin/admin-read-service.js
index 513f5d14..25678d96 100644
--- a/apps/backend/src/services/admin/admin-read-service.js
+++ b/apps/backend/src/services/admin/admin-read-service.js
@@ -14,6 +14,7 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
import {
+ canViewerCloseTask,
canRegenerateClaimLinkForViewer,
canViewerConfirmAssistedRole,
canViewerRedeemAssistedTask,
@@ -281,7 +282,7 @@ export async function getAdminTaskDetail(taskId, session = null) {
&& ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease),
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),
canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status),
canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext),
@@ -295,18 +296,6 @@ export async function getAdminTaskDetail(taskId, session = null) {
/** @param {AdminViewerSessionInput | null} [session] */
export async function getAdminTaskScreenshotPath(taskId, session = null) {
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) {
return task.screenshot_path
@@ -316,7 +305,7 @@ export async function getAdminTaskScreenshotPath(taskId, session = null) {
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
}
- throw createHttpError('当前任务还没有截图', {
+ throw createHttpError('当前任务还没有可查看截图', {
statusCode: 404,
errorCode: 'admin_task_screenshot_not_found',
})
diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.js b/apps/backend/src/services/admin/admin-read-shared-helpers.js
index 28b01ce9..7d2be6f1 100644
--- a/apps/backend/src/services/admin/admin-read-shared-helpers.js
+++ b/apps/backend/src/services/admin/admin-read-shared-helpers.js
@@ -68,10 +68,6 @@ export function createAdminViewerContext(session = null) {
}
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) {
return `/api/v1/admin/tasks/${task.id}/screenshot`
}
@@ -79,6 +75,14 @@ export async function resolveAdminTaskScreenshotUrl(task, viewerContext) {
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) {
const normalizedShopName = String(shopName || '').trim()
if (normalizedShopName) {
diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.test.js b/apps/backend/src/services/admin/admin-read-shared-helpers.test.js
new file mode 100644
index 00000000..94a82325
--- /dev/null
+++ b/apps/backend/src/services/admin/admin-read-shared-helpers.test.js
@@ -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)
+})
diff --git a/apps/frontend/src/views/admin/AdminTaskDetailView.vue b/apps/frontend/src/views/admin/AdminTaskDetailView.vue
index 3a75c9d9..b1518234 100644
--- a/apps/frontend/src/views/admin/AdminTaskDetailView.vue
+++ b/apps/frontend/src/views/admin/AdminTaskDetailView.vue
@@ -33,7 +33,8 @@ const manualDispatchForm = reactive({
deliveredCredential: '',
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 claimUrl = computed(() => {
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
@@ -304,6 +305,7 @@ onBeforeUnmount(clearScreenshotPreview)
{{ errorMessage }}
@@ -277,6 +282,7 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {