Files
order_site/apps/frontend/src/views/admin/AdminTasksView.vue
T
2026-05-15 03:22:44 +08:00

419 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
import {
closeAdminTask,
fetchAdminTasks,
markAdminTaskManualReview,
regenerateAdminTaskClaimLink,
retryAdminTask,
} from '@/services/admin'
import type { AdminPagination, AdminTaskActionResponse, AdminTaskListItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { adminTaskStatusOptions } from '@/utils/admin-options'
import { formatAdminDateTime } from '@/utils/admin-time'
const loading = ref(true)
const actionLoadingId = ref<number | null>(null)
const errorMessage = ref('')
const status = ref('')
const taskNo = ref('')
const platformOrderId = ref('')
const roleId = ref('')
const skuCode = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const items = ref<AdminTaskListItem[]>([])
const lastClaimUrl = ref('')
const pagination = ref<AdminPagination>({
page: 1,
pageSize: 20,
total: 0,
})
const canManageTaskLifecycle = hasAdminRole('operator')
const canCloseTasks = hasAdminRole('support')
type AdminTaskActionKey = 'retry' | 'regenerate_claim_link' | 'manual_review' | 'close'
async function loadTasks(page = pagination.value.page) {
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminTasks({
page,
pageSize: pagination.value.pageSize,
status: status.value.trim(),
taskNo: taskNo.value.trim(),
platformOrderId: platformOrderId.value.trim(),
roleId: roleId.value.trim(),
skuCode: skuCode.value.trim(),
dateFrom: dateFrom.value.trim(),
dateTo: dateTo.value.trim(),
})
items.value = response.data.items
pagination.value = response.data.pagination
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取任务列表失败'
} finally {
loading.value = false
}
}
function formatBindingRoles(item: AdminTaskListItem) {
const roles = item.bindingSummary.roleKeys
if (!Array.isArray(roles) || roles.length === 0) return '无绑定角色'
return roles.join(' / ')
}
function formatBindingCountSummary(item: AdminTaskListItem) {
const summary = item.bindingSummary
return `共 ${summary.totalBindingCount} 条 · 预占 ${summary.reservedBindingCount} · 已消费 ${summary.consumedBindingCount} · 已释放 ${summary.releasedBindingCount}`
}
onMounted(() => { void loadTasks() })
async function runAction(
taskId: number,
action: () => Promise<{ data: AdminTaskActionResponse }>,
successMessage: string,
confirmText: string,
) {
try {
await showConfirm(confirmText, '确认操作', { type: 'warning', confirmButtonText: '继续执行', cancelButtonText: '取消' })
} catch { return }
actionLoadingId.value = taskId
try {
const response = await action()
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl
showSuccess(successMessage)
await loadTasks()
} catch (error) {
showError(error instanceof Error ? error.message : '操作失败')
} finally { actionLoadingId.value = null }
}
function handleActionCommand(actionKey: AdminTaskActionKey, item: AdminTaskListItem) {
if (actionKey === 'retry') {
void runAction(item.taskId, () => retryAdminTask(item.taskId), '任务已重试', `确认重试任务 ${item.taskNo} 吗?`)
return
}
if (actionKey === 'regenerate_claim_link') {
void runAction(item.taskId, () => regenerateAdminTaskClaimLink(item.taskId), '领取链接已重新生成', `确认重新生成任务 ${item.taskNo} 的领取链接吗?旧链接会失效。`)
return
}
if (actionKey === 'manual_review') {
void runAction(item.taskId, () => markAdminTaskManualReview(item.taskId), '任务已转人工处理', `确认将任务 ${item.taskNo} 转为人工处理吗?`)
return
}
void runAction(item.taskId, () => closeAdminTask(item.taskId), '任务已关闭', `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`)
}
function resetFilters() {
status.value = ''
taskNo.value = ''
platformOrderId.value = ''
roleId.value = ''
skuCode.value = ''
dateFrom.value = ''
dateTo.value = ''
void loadTasks(1)
}
</script>
<template>
<div class="tasks-page">
<AdminPageHeader title="交付任务" description="更紧凑地查看任务状态、角色绑定、库存凭据和异常信息。">
<template #extra>
<span class="total-badge">共 {{ pagination.total }} 条任务</span>
</template>
</AdminPageHeader>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">查询任务</span>
<span class="card-desc">按状态、任务号、订单号、角色标识、SKU 和日期筛选。</span>
</div>
</template>
<div class="filter-grid">
<el-select v-model="status" placeholder="状态" clearable>
<el-option v-for="opt in adminTaskStatusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-input v-model="taskNo" placeholder="任务号(模糊匹配)" clearable @keyup.enter="loadTasks(1)" />
<el-input v-model="platformOrderId" placeholder="平台订单号" clearable @keyup.enter="loadTasks(1)" />
<el-input v-model="roleId" placeholder="角色唯一标识" clearable @keyup.enter="loadTasks(1)" />
<el-input v-model="skuCode" placeholder="商品 SKU" clearable @keyup.enter="loadTasks(1)" />
<el-date-picker v-model="dateFrom" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" />
<el-date-picker v-model="dateTo" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" />
<div class="filter-buttons">
<el-button round @click="resetFilters">重置</el-button>
<el-button round type="primary" @click="loadTasks(1)">查询</el-button>
</div>
</div>
</el-card>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" style="margin-top: 16px" />
<el-alert v-if="lastClaimUrl" :title="`最近生成的领取链接:${lastClaimUrl}`" type="info" show-icon :closable="false" style="margin-top: 16px" />
<el-card shadow="never" class="section-card" v-loading="loading" element-loading-text="任务列表加载中">
<template v-if="items.length === 0 && !loading">
<el-empty description="当前筛选条件下暂无任务" :image-size="60" />
</template>
<template v-else>
<div class="tasks-toolbar">
<strong>任务列表</strong>
<span>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span>
</div>
<el-table :data="items" stripe size="small" class="tasks-table">
<el-table-column label="任务 / 商品" min-width="220">
<template #default="{ row }">
<div class="cell-stack">
<RouterLink :to="`/admin/tasks/${row.taskId}`" class="task-no" :title="row.taskNo">
{{ row.taskNo }}
</RouterLink>
<div class="cell-meta-row">
<span class="compact-chip" :title="row.platformOrderId">订单 {{ row.platformOrderId }}</span>
<span class="compact-chip compact-chip--muted">重试 {{ row.retryCount }} 次</span>
</div>
<span class="cell-title" :title="row.skuName || row.skuCode">{{ row.skuName || row.skuCode }}</span>
<span class="cell-subline">SKU {{ row.skuCode || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="进度" min-width="160">
<template #default="{ row }">
<div class="cell-stack">
<div class="progress-row">
<span class="progress-label">任务</span>
<AdminStatusTag :status="row.status" />
</div>
<div class="progress-row">
<span class="progress-label">系统</span>
<AdminStatusTag :status="row.systemBindingStatus" />
</div>
<div class="progress-row">
<span class="progress-label">完整</span>
<AdminStatusTag :status="row.userBindingStatus" />
</div>
</div>
</template>
</el-table-column>
<el-table-column label="角色 / 凭据" min-width="180">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title" :title="row.roleName || row.roleId || '-'">{{ row.roleName || '-' }}</span>
<span class="cell-subline" :title="row.roleId || '-'">角色ID {{ row.roleId || '-' }}</span>
<span class="cell-subline">登录 {{ row.loginType || '-' }}</span>
<span class="cell-subline" :title="row.inventoryDisplayMasked || '-'">凭据 {{ row.inventoryDisplayMasked || '-' }}</span>
<span class="cell-subline" :title="formatBindingRoles(row)">绑定 {{ formatBindingRoles(row) }}</span>
<span class="cell-subline cell-subline--strong" :title="formatBindingCountSummary(row)">{{ formatBindingCountSummary(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="180">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-subline">创建 {{ formatAdminDateTime(row.createdAt) }}</span>
<span class="cell-subline">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
<span v-if="row.claimedAt" class="cell-subline">领取 {{ formatAdminDateTime(row.claimedAt) }}</span>
<span v-if="row.roleConfirmedAt" class="cell-subline">确认 {{ formatAdminDateTime(row.roleConfirmedAt) }}</span>
<span v-if="row.redeemedAt" class="cell-subline">兑换 {{ formatAdminDateTime(row.redeemedAt) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="异常 / 操作" min-width="220">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-error" :title="row.lastError || '-'">{{ row.lastError || '当前无异常' }}</span>
<div class="action-stack">
<el-button
v-if="canManageTaskLifecycle"
:disabled="row.executorKey === 'manual_dispatch' || !['retry_pending', 'manual_review', 'waiting_inventory'].includes(row.status)"
:loading="actionLoadingId === row.taskId"
size="small"
type="danger"
plain
@click="runAction(row.taskId, () => retryAdminTask(row.taskId), '任务已重试', `确认重试任务 ${row.taskNo} 吗?`)"
>
重试
</el-button>
<el-button
v-if="canManageTaskLifecycle && row.executorKey !== 'manual_dispatch'"
size="small"
@click="handleActionCommand('regenerate_claim_link', row)"
>
重发链接
</el-button>
<el-button
v-if="canManageTaskLifecycle"
size="small"
@click="handleActionCommand('manual_review', row)"
>
转人工
</el-button>
<el-button
v-if="canCloseTasks && !['redeemed', 'closed'].includes(row.status)"
size="small"
type="danger"
@click="handleActionCommand('close', row)"
>
关闭任务
</el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="pagination.total > 0"
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
style="margin-top: 16px; justify-content: center"
@current-change="loadTasks"
/>
</template>
</el-card>
</div>
</template>
<style scoped>
.tasks-page { display: grid; gap: 0; }
.total-badge {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border: 1px solid rgba(37, 89, 167, 0.14);
border-radius: var(--radius-full);
background: rgba(37, 89, 167, 0.08);
color: #2559a7;
font-size: var(--text-xs);
font-weight: 700;
}
.section-card { margin-top: var(--space-4); }
.card-header { display: flex; justify-content: space-between; gap: var(--space-3); align-items: flex-end; }
.card-title { color: var(--text-primary); font-size: var(--text-lg); font-weight: 600; }
.card-desc { color: var(--text-secondary); font-size: var(--text-sm); }
.filter-grid {
display: grid;
grid-template-columns: minmax(150px, 0.75fr) minmax(220px, 1fr) minmax(220px, 1fr) minmax(210px, 0.95fr);
gap: var(--space-3);
align-items: center;
}
.filter-grid :deep(.el-date-editor) { width: 100%; }
.filter-buttons { display: flex; gap: 10px; justify-content: flex-end; min-width: 156px; }
.tasks-toolbar { margin-bottom: var(--space-3); }
.tasks-toolbar strong { display: block; color: var(--text-primary); font-size: var(--text-base); }
.tasks-toolbar span { display: block; margin-top: 4px; color: var(--text-secondary); font-size: var(--text-xs); }
.tasks-table {
width: 100%;
overflow: hidden;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;
--el-table-header-bg-color: rgba(239, 246, 255, 0.72);
--el-table-row-hover-bg-color: rgba(239, 246, 255, 0.72);
--el-table-border-color: rgba(86, 108, 138, 0.1);
}
.tasks-table :deep(.el-table__inner-wrapper::before) { display: none; }
.tasks-table :deep(th.el-table__cell) {
height: 42px;
background: var(--el-table-header-bg-color);
color: var(--text-secondary);
font-weight: 700;
}
.cell-stack { display: grid; gap: 5px; }
.task-no {
display: inline-block;
max-width: 100%;
font-size: var(--text-base);
font-weight: 800;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
}
.task-no:hover { color: var(--color-primary-dark); }
.cell-meta-row { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.compact-chip {
display: inline-flex;
align-items: center;
min-height: 24px;
max-width: 100%;
padding: 0 9px;
border-radius: var(--radius-full);
background: #f4f7fb;
border: 1px solid var(--border-default);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 600;
}
.compact-chip--muted { color: var(--text-muted); }
.cell-title { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary); font-weight: 700; }
.cell-subline { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475467; font-size: var(--text-xs); }
.cell-subline--strong { color: var(--text-primary); font-weight: 600; }
.progress-row { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
.progress-row :deep(.el-tag) { min-width: 0; max-width: calc(100% - 42px); }
.progress-row :deep(.el-tag__content) { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.progress-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
min-height: 22px;
padding: 0 7px;
border-radius: var(--radius-full);
background: #f7f9fc;
border: 1px solid var(--border-default);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.cell-error { display: block; min-width: 0; line-height: 1.55; color: #475467; word-break: break-word; font-size: var(--text-xs); }
.action-stack { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.action-stack :deep(.el-button) { flex: 0 0 auto; margin-left: 0; }
@media (max-width: 1200px) {
.filter-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.filter-buttons { justify-content: flex-start; }
}
@media (max-width: 900px) {
.filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.filter-grid { grid-template-columns: 1fr; }
.filter-buttons { width: 100%; }
.filter-buttons :deep(.el-button) { flex: 1 1 auto; }
.task-no { font-size: var(--text-base); }
}
</style>