480 lines
20 KiB
Vue
480 lines
20 KiB
Vue
<script setup lang="ts">
|
||
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,
|
||
regenerateAdminTaskClaimLink,
|
||
releaseAdminTaskInventoryBinding,
|
||
retryAdminTask,
|
||
} from '@/services/admin'
|
||
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
|
||
import { hasAdminRole } from '@/utils/admin-auth'
|
||
import { formatTaskEventType } from '@/utils/admin-display'
|
||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||
|
||
const route = useRoute()
|
||
const loading = ref(true)
|
||
const actionLoading = ref(false)
|
||
const errorMessage = ref('')
|
||
const detail = ref<AdminTaskDetail | null>(null)
|
||
const lastClaimUrl = ref('')
|
||
const screenshotPreviewUrl = ref('')
|
||
const manualDispatchForm = reactive({
|
||
deliveryReference: '',
|
||
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
|
||
errorMessage.value = ''
|
||
|
||
try {
|
||
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
|
||
detail.value = response.data
|
||
syncManualDispatchForm(response.data)
|
||
await loadScreenshotPreview(response.data)
|
||
} catch (error) {
|
||
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function runAction(
|
||
action: () => Promise<{ data: AdminTaskActionResponse }>,
|
||
successMessage: string,
|
||
confirmText: string,
|
||
) {
|
||
try {
|
||
await showConfirm(confirmText, '确认操作', {
|
||
type: 'warning',
|
||
confirmButtonText: '继续执行',
|
||
cancelButtonText: '取消',
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
actionLoading.value = true
|
||
|
||
try {
|
||
const response = await action()
|
||
if (response.data.claimUrl) {
|
||
lastClaimUrl.value = response.data.claimUrl
|
||
}
|
||
showSuccess(successMessage)
|
||
await loadDetail()
|
||
} catch (error) {
|
||
showError(error instanceof Error ? error.message : '操作失败')
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadScreenshotPreview(taskDetail: AdminTaskDetail) {
|
||
clearScreenshotPreview()
|
||
|
||
if (!taskDetail.screenshotUrl) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const blob = await fetchAdminTaskScreenshot(taskDetail.task.taskId)
|
||
screenshotPreviewUrl.value = URL.createObjectURL(blob)
|
||
} catch {
|
||
screenshotPreviewUrl.value = ''
|
||
}
|
||
}
|
||
|
||
function clearScreenshotPreview() {
|
||
if (screenshotPreviewUrl.value) {
|
||
URL.revokeObjectURL(screenshotPreviewUrl.value)
|
||
screenshotPreviewUrl.value = ''
|
||
}
|
||
}
|
||
|
||
function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
|
||
manualDispatchForm.deliveryReference = taskDetail.manualDispatch?.deliveryReference || ''
|
||
manualDispatchForm.deliveredCredential = taskDetail.manualDispatch?.deliveredCredential || ''
|
||
manualDispatchForm.resultMessage = taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || ''
|
||
}
|
||
|
||
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
|
||
if (!detail.value) {
|
||
return
|
||
}
|
||
|
||
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
|
||
const confirmText = outcome === 'failed'
|
||
? `确认把任务 ${detail.value.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
|
||
: `确认把任务 ${detail.value.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
|
||
|
||
await runAction(
|
||
() => completeAdminTaskManualDispatch(detail.value!.task.taskId, {
|
||
outcome,
|
||
resultMessage: manualDispatchForm.resultMessage,
|
||
deliveryReference: manualDispatchForm.deliveryReference,
|
||
deliveredCredential: manualDispatchForm.deliveredCredential,
|
||
}),
|
||
actionLabel,
|
||
confirmText,
|
||
)
|
||
}
|
||
|
||
function formatTaskEventPayload(payload: Record<string, unknown>) {
|
||
const parts = [
|
||
payload.outcome ? `结果 ${payload.outcome}` : '',
|
||
payload.resultCode ? `代码 ${payload.resultCode}` : '',
|
||
payload.resultMessage ? `说明 ${payload.resultMessage}` : '',
|
||
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
|
||
].filter(Boolean)
|
||
|
||
if (parts.length > 0) {
|
||
return parts.join(' · ')
|
||
}
|
||
|
||
return JSON.stringify(payload || {})
|
||
}
|
||
|
||
onMounted(loadDetail)
|
||
onBeforeUnmount(clearScreenshotPreview)
|
||
</script>
|
||
|
||
<template>
|
||
<section class="admin-panel">
|
||
<header class="panel-header">
|
||
<div>
|
||
<h1>任务详情</h1>
|
||
<p>查看任务全量信息并执行人工操作。</p>
|
||
</div>
|
||
</header>
|
||
|
||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||
<div v-if="loading" class="empty-block">任务详情加载中</div>
|
||
|
||
<template v-else-if="detail">
|
||
<section class="info-card">
|
||
<h2>{{ detail.task.taskNo }}</h2>
|
||
<p>状态:<AdminStatusTag :status="detail.task.status" /> · 订单:{{ detail.order?.platformOrderId || '-' }}</p>
|
||
<p>执行器:{{ detail.task.executorKey || '-' }} · 履约:<AdminStatusTag :status="detail.task.deliveryStatus" /></p>
|
||
<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="claimUrl">
|
||
领取链接:
|
||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||
</p>
|
||
</section>
|
||
|
||
<section class="action-row">
|
||
<el-button
|
||
:disabled="!detail.operations.canRetry"
|
||
:loading="actionLoading"
|
||
round
|
||
type="primary"
|
||
@click="runAction(() => retryAdminTask(detail!.task.taskId), '任务已重试', `确认重试任务 ${detail!.task.taskNo} 吗?`)"
|
||
>
|
||
重试任务
|
||
</el-button>
|
||
<el-button
|
||
v-if="detail.operations.canRegenerateClaimLink"
|
||
:disabled="!detail.operations.canRegenerateClaimLink"
|
||
:loading="actionLoading"
|
||
round
|
||
@click="runAction(() => regenerateAdminTaskClaimLink(detail!.task.taskId), '领取链接已重新生成', `确认重新生成任务 ${detail!.task.taskNo} 的领取链接吗?旧链接会失效。`)"
|
||
>
|
||
重发链接
|
||
</el-button>
|
||
<el-button
|
||
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
|
||
@click="runAction(() => markAdminTaskManualReview(detail!.task.taskId), '任务已转人工处理', `确认将任务 ${detail!.task.taskNo} 转为人工处理吗?`)"
|
||
>
|
||
转人工
|
||
</el-button>
|
||
<el-button
|
||
v-if="canOperateTasks && detail.operations.canCompleteManualDispatch"
|
||
:loading="actionLoading"
|
||
round
|
||
type="success"
|
||
@click="submitManualDispatch('delivered')"
|
||
>
|
||
人工完成
|
||
</el-button>
|
||
<el-button
|
||
v-if="canOperateTasks && detail.operations.canCompleteManualDispatch"
|
||
:loading="actionLoading"
|
||
round
|
||
type="danger"
|
||
plain
|
||
@click="submitManualDispatch('failed')"
|
||
>
|
||
履约失败
|
||
</el-button>
|
||
<el-button
|
||
v-if="canOperateTasks"
|
||
:disabled="!detail.operations.canClose"
|
||
:loading="actionLoading"
|
||
round
|
||
type="danger"
|
||
@click="runAction(() => closeAdminTask(detail!.task.taskId), '任务已关闭', `确认关闭任务 ${detail!.task.taskNo} 吗?关闭后不会自动继续推进。`)"
|
||
>
|
||
关闭任务
|
||
</el-button>
|
||
</section>
|
||
|
||
<section class="table-card">
|
||
<h3>任务信息</h3>
|
||
<table class="data-table">
|
||
<tbody>
|
||
<tr><th>商品</th><td>{{ detail.orderItem?.skuName || '-' }}</td></tr>
|
||
<tr><th>SKU</th><td>{{ detail.orderItem?.skuCode || '-' }}</td></tr>
|
||
<tr><th>任务执行器</th><td>{{ detail.task.executorKey || '-' }}</td></tr>
|
||
<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 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>
|
||
<tr><th>完整绑定成功</th><td>{{ formatAdminDateTime(detail.task.redeemedAt) }}</td></tr>
|
||
<tr><th>截图路径</th><td>{{ detail.task.screenshotPath || '-' }}</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="table-card">
|
||
<h3>库存绑定</h3>
|
||
<p class="section-copy">这里展示任务当前和历史绑定过的库存项,便于核对多库存任务的真实履约轨迹。</p>
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>绑定</th>
|
||
<th>角色</th>
|
||
<th>库存项</th>
|
||
<th>状态</th>
|
||
<th>凭据内容</th>
|
||
<th>时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-if="detail.inventoryBindings.length === 0">
|
||
<td colspan="7" class="table-empty">当前任务还没有库存绑定记录</td>
|
||
</tr>
|
||
<tr v-for="binding in detail.inventoryBindings" :key="binding.bindingId">
|
||
<td>
|
||
<div>#{{ binding.bindingId }}</div>
|
||
<div v-if="binding.isPrimary" class="binding-primary">主库存项</div>
|
||
</td>
|
||
<td>
|
||
<div>{{ binding.roleKey || '-' }}</div>
|
||
<div class="binding-meta">数量 {{ binding.quantity }}</div>
|
||
</td>
|
||
<td>
|
||
<div>ID {{ binding.inventoryItemId }}</div>
|
||
<div>{{ binding.skuCode || '-' }}</div>
|
||
<div class="binding-meta">{{ binding.credentialType || '-' }} · {{ binding.batchNo || '无批次' }}</div>
|
||
</td>
|
||
<td>
|
||
<div class="status-stack">
|
||
<AdminStatusTag :status="binding.bindingStatus" />
|
||
<AdminStatusTag :status="binding.inventoryStatus" />
|
||
</div>
|
||
<div v-if="binding.invalidReason" class="binding-meta">作废原因:{{ binding.invalidReason }}</div>
|
||
</td>
|
||
<td>{{ canViewSensitiveTaskData ? (binding.displayValue || '-') : '客服不可见' }}</td>
|
||
<td>
|
||
<div>绑定 {{ formatAdminDateTime(binding.createdAt) }}</div>
|
||
<div class="binding-meta">消费 {{ formatAdminDateTime(binding.consumedAt) }}</div>
|
||
<div class="binding-meta">释放 {{ formatAdminDateTime(binding.releasedAt) }}</div>
|
||
</td>
|
||
<td>
|
||
<el-button
|
||
v-if="hasAdminRole('admin') && binding.canRelease"
|
||
:loading="actionLoading"
|
||
link
|
||
@click="runAction(
|
||
() => releaseAdminTaskInventoryBinding(detail!.task.taskId, binding.bindingId),
|
||
'库存绑定已释放',
|
||
`确认释放绑定 #${binding.bindingId} 对应的库存项 ${binding.displayValue || binding.inventoryItemId} 吗?`
|
||
)"
|
||
>
|
||
释放绑定
|
||
</el-button>
|
||
<span v-else class="binding-meta">-</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section v-if="detail.task.executorKey === 'manual_dispatch'" class="table-card">
|
||
<h3>人工履约回写</h3>
|
||
<div class="manual-grid">
|
||
<label class="field-block">
|
||
<span>外部流水 / 单号</span>
|
||
<el-input
|
||
v-model="manualDispatchForm.deliveryReference"
|
||
:disabled="!canOperateTasks"
|
||
maxlength="120"
|
||
placeholder="例如快递单号、平台消息回执号"
|
||
/>
|
||
</label>
|
||
<label class="field-block">
|
||
<span>已交付内容</span>
|
||
<el-input
|
||
v-model="manualDispatchForm.deliveredCredential"
|
||
:disabled="!canOperateTasks"
|
||
maxlength="500"
|
||
placeholder="可填写人工发送的卡密、链接或关键信息"
|
||
type="textarea"
|
||
:rows="3"
|
||
/>
|
||
</label>
|
||
<label class="field-block">
|
||
<span>处理备注</span>
|
||
<el-input
|
||
v-model="manualDispatchForm.resultMessage"
|
||
:disabled="!canOperateTasks"
|
||
maxlength="500"
|
||
placeholder="说明实际履约结果,失败时建议写清原因"
|
||
type="textarea"
|
||
:rows="4"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<p class="manual-hint">回写后会直接更新任务终态,不再走旧式领取链接流程。</p>
|
||
<div v-if="detail.manualDispatch" class="manual-summary">
|
||
<p>最近回写:<AdminStatusTag :status="detail.manualDispatch.outcome || detail.task.deliveryStatus" /> · {{ formatAdminDateTime(detail.manualDispatch.completedAt) }}</p>
|
||
<p>处理人:{{ detail.manualDispatch.completedBy?.username || '-' }} / {{ detail.manualDispatch.completedBy?.role || '-' }}</p>
|
||
<p>流水号:{{ detail.manualDispatch.deliveryReference || '-' }}</p>
|
||
<p>交付内容:{{ detail.manualDispatch.deliveredCredential || '-' }}</p>
|
||
<p>备注:{{ detail.manualDispatch.resultMessage || '-' }}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="detail.events.length > 0" class="table-card">
|
||
<h3>任务事件</h3>
|
||
<table class="data-table">
|
||
<tbody>
|
||
<tr v-for="event in detail.events" :key="event.eventId">
|
||
<th>{{ formatTaskEventType(event.eventType) }}</th>
|
||
<td>
|
||
<div class="event-copy">{{ formatTaskEventPayload(event.payload) }}</div>
|
||
<div class="event-time">{{ formatAdminDateTime(event.createdAt) }}</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section v-if="detail.screenshotUrl" class="table-card">
|
||
<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="screenshotSectionTitle" />
|
||
</section>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.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);
|
||
}
|
||
.error-copy { color: #b42318; }
|
||
.action-row { display: flex; flex-wrap: wrap; gap: 12px; }
|
||
.table-card h3 { margin: 0 0 12px; color: #1d3555; }
|
||
.section-copy { margin: 0 0 12px; color: #64748b; }
|
||
.data-table { width: 100%; border-collapse: collapse; }
|
||
.data-table th,.data-table td { padding: 12px 10px; text-align: left; border-bottom: 1px solid rgba(86,108,138,.08); }
|
||
.data-table th { width: 160px; color: #64748b; }
|
||
.status-stack { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.table-empty { text-align: center; color: #64748b; }
|
||
.binding-primary { margin-top: 4px; color: #0f766e; font-size: 12px; font-weight: 600; }
|
||
.binding-meta { margin-top: 4px; color: #64748b; font-size: 12px; }
|
||
.manual-grid { display: grid; gap: 14px; }
|
||
.field-block { display: grid; gap: 8px; color: #1d3555; font-weight: 600; }
|
||
.manual-hint { margin: 14px 0 0; color: #64748b; }
|
||
.manual-summary { margin-top: 16px; padding: 14px; border-radius: 16px; background: rgba(248,250,252,.9); }
|
||
.manual-summary p { margin: 0 0 8px; color: #334155; }
|
||
.manual-summary p:last-child { margin-bottom: 0; }
|
||
.event-copy { color: #334155; }
|
||
.event-time { margin-top: 6px; color: #64748b; font-size: 12px; }
|
||
.screenshot-link { display: inline-block; margin-bottom: 12px; color: #175cd3; text-decoration: none; }
|
||
.screenshot-empty { margin: 0 0 12px; color: #64748b; }
|
||
.screenshot-preview {
|
||
display: block;
|
||
width: min(100%, 720px);
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(86,108,138,.12);
|
||
box-shadow: 0 20px 48px rgba(15,23,42,.08);
|
||
}
|
||
</style>
|