彻底重构-4

This commit is contained in:
yml
2026-04-09 15:51:24 +08:00
parent 60054adcc4
commit 4c395103a9
28 changed files with 1917 additions and 404 deletions
@@ -1,20 +1,22 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import {
closeAdminTask,
completeAdminTaskManualDispatch,
fetchAdminTaskScreenshot,
fetchAdminTaskDetail,
markAdminTaskManualReview,
regenerateAdminTaskClaimLink,
releaseAdminTaskCdk,
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()
@@ -24,6 +26,11 @@ const errorMessage = ref('')
const detail = ref<AdminTaskDetail | null>(null)
const lastClaimUrl = ref('')
const screenshotPreviewUrl = ref('')
const manualDispatchForm = reactive({
deliveryReference: '',
deliveredCredential: '',
resultMessage: '',
})
async function loadDetail() {
loading.value = true
@@ -32,6 +39,7 @@ async function loadDetail() {
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 : '读取任务详情失败'
@@ -93,6 +101,49 @@ function clearScreenshotPreview() {
}
}
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>
@@ -113,6 +164,7 @@ onBeforeUnmount(clearScreenshotPreview)
<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>
@@ -129,15 +181,6 @@ onBeforeUnmount(clearScreenshotPreview)
>
重试任务
</el-button>
<el-button
v-if="hasAdminRole('admin')"
:disabled="!detail.operations.canReleaseCdk"
:loading="actionLoading"
round
@click="runAction(() => releaseAdminTaskCdk(detail!.task.taskId), 'CDK 已释放', `确认释放任务 ${detail!.task.taskNo} 当前预占的 CDK 吗?`)"
>
释放 CDK
</el-button>
<el-button
v-if="hasAdminRole('admin')"
:disabled="!detail.operations.canRegenerateClaimLink"
@@ -156,6 +199,25 @@ onBeforeUnmount(clearScreenshotPreview)
>
转人工
</el-button>
<el-button
v-if="hasAdminRole('admin') && detail.operations.canCompleteManualDispatch"
:loading="actionLoading"
round
type="success"
@click="submitManualDispatch('delivered')"
>
人工完成
</el-button>
<el-button
v-if="hasAdminRole('admin') && detail.operations.canCompleteManualDispatch"
:loading="actionLoading"
round
type="danger"
plain
@click="submitManualDispatch('failed')"
>
履约失败
</el-button>
<el-button
v-if="hasAdminRole('admin')"
:disabled="!detail.operations.canClose"
@@ -174,8 +236,13 @@ onBeforeUnmount(clearScreenshotPreview)
<tbody>
<tr><th>商品</th><td>{{ detail.orderItem?.skuName || '-' }}</td></tr>
<tr><th>SKU</th><td>{{ detail.orderItem?.skuCode || '-' }}</td></tr>
<tr><th>预占 CDK</th><td>{{ detail.cdk?.cdkCode || '-' }}</td></tr>
<tr><th>CDK 状态</th><td><AdminStatusTag :status="detail.cdk?.status || ''" /></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><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><th>Token 状态</th><td><AdminStatusTag :status="detail.claimToken?.status || ''" /></td></tr>
<tr><th>Token 过期</th><td>{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}</td></tr>
@@ -188,6 +255,129 @@ onBeforeUnmount(clearScreenshotPreview)
</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>{{ 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"
maxlength="120"
placeholder="例如快递单号、平台消息回执号"
/>
</label>
<label class="field-block">
<span>已交付内容</span>
<el-input
v-model="manualDispatchForm.deliveredCredential"
maxlength="500"
placeholder="可填写人工发送的卡密、链接或关键信息"
type="textarea"
:rows="3"
/>
</label>
<label class="field-block">
<span>处理备注</span>
<el-input
v-model="manualDispatchForm.resultMessage"
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>结果截图</h3>
<p v-if="!screenshotPreviewUrl" class="screenshot-empty">截图加载中或当前不可读取</p>
@@ -208,9 +398,22 @@ onBeforeUnmount(clearScreenshotPreview)
.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 {