Files
order_site/apps/frontend/src/views/admin/tasks/AdminTasksView.vue
T

496 lines
15 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 { useAdminAction } from '@/composables/useAdminAction'
import { useAdminListPage } from '@/composables/useAdminListPage'
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import {
closeAdminTask,
fetchAdminTasks,
markAdminTaskManualReview,
rebindAdminTaskKuaishouCloudRole,
retryAdminTask,
} from '@/services/admin'
import type { AdminTaskListItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { adminTaskStatusOptions } from '@/utils/admin-options'
import { formatAdminDateTime } from '@/utils/admin-time'
const status = ref('')
const taskNo = ref('')
const platformOrderId = ref('')
const roleId = ref('')
const skuCode = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const lastClaimUrl = ref('')
const canManageTaskLifecycle = hasAdminRole('operator')
const canCloseTasks = hasAdminRole('support')
type AdminTaskActionKey = 'retry' | 'rebind_role' | 'manual_review' | 'close'
const {
loading,
errorMessage,
items,
pagination,
loadPage: loadTasks,
} = useAdminListPage<AdminTaskListItem>({
defaultErrorMessage: '读取任务列表失败',
fetchPage: (page, pageSize) =>
fetchAdminTasks({
page,
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(),
}),
})
const { actionLoadingId, runAction } = useAdminAction()
function handleActionCommand(actionKey: AdminTaskActionKey, item: AdminTaskListItem) {
if (actionKey === 'retry') {
void runAction({
id: item.taskId,
action: async () => {
const response = await retryAdminTask(item.taskId)
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl
},
successMessage: '任务已重试',
confirmText: `确认重试任务 ${item.taskNo} 吗?`,
onAfterAction: async () => {
await loadTasks()
},
})
return
}
if (actionKey === 'rebind_role') {
void runAction({
id: item.taskId,
action: async () => {
const response = await rebindAdminTaskKuaishouCloudRole(item.taskId)
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl
},
successMessage: '角色换绑资源已准备完成',
confirmText: `确认为任务 ${item.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
onAfterAction: async () => {
await loadTasks()
},
})
return
}
if (actionKey === 'manual_review') {
void runAction({
id: item.taskId,
action: () => markAdminTaskManualReview(item.taskId),
successMessage: '任务已转人工处理',
confirmText: `确认将任务 ${item.taskNo} 转为人工处理吗?`,
onAfterAction: async () => {
await loadTasks()
},
})
return
}
void runAction({
id: item.taskId,
action: () => closeAdminTask(item.taskId),
successMessage: '任务已关闭',
confirmText: `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`,
onAfterAction: async () => {
await loadTasks()
},
})
}
function resetFilters() {
status.value = ''
taskNo.value = ''
platformOrderId.value = ''
roleId.value = ''
skuCode.value = ''
dateFrom.value = ''
dateTo.value = ''
void loadTasks(1)
}
function resolveResourceStageLabel(status: string) {
const normalized = String(status || '').trim()
const labelMap: Record<string, string> = {
resource_ready: '资源已准备',
pending_prepare: '待准备资源',
pending_payment: '待支付',
pending_binding_prepare: '待准备资源',
retry_pending: '待重试',
manual_review: '人工处理',
resource_exception: '资源异常',
closed: '已关闭',
expired: '已过期',
completed: '已完成',
not_started: '未开始',
}
return labelMap[normalized] || normalized || '-'
}
function resolveCustomerStageLabel(status: string) {
const normalized = String(status || '').trim()
const labelMap: Record<string, string> = {
not_started: '待打开链接',
waiting_customer: '待客户绑定',
link_opened: '已打开链接',
customer_processing: '处理中',
customer_confirmed: '已提交',
customer_completed: '客户已完成',
customer_exception: '客户步骤异常',
closed: '已关闭',
expired: '已过期',
}
return labelMap[normalized] || normalized || '-'
}
function resolveTaskMainText(item: AdminTaskListItem) {
if (item.lastError) return '需要关注'
if (['failed', 'manual_review', 'retry_pending'].includes(item.status)) return '待处理'
if (['redeemed', 'completed', 'closed'].includes(item.status)) return '已完成'
if (item.customerStatus === 'waiting_customer') return '等待客户绑定'
if (item.resourceStatus === 'resource_ready') return '资源已准备'
if (item.status === 'pending_binding_prepare') return '待准备资源'
return item.status || '处理中'
}
function resolveTaskTone(item: AdminTaskListItem) {
if (item.lastError || ['failed', 'manual_review', 'retry_pending'].includes(item.status))
return 'danger'
if (['redeemed', 'completed', 'closed'].includes(item.status)) return 'success'
return 'warning'
}
function canRebindListTask(item: AdminTaskListItem) {
return (
canManageTaskLifecycle &&
item.executorKey === 'kuaishou_ct_assisted' &&
['waiting_binding', 'role_confirmed', 'manual_review', 'retry_pending'].includes(item.status)
)
}
onMounted(() => {
void loadTasks(1)
})
</script>
<template>
<div class="tasks-page list-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">按状态任务号订单号商品和日期筛选</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="角色 ID" 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"
class="mt-4"
/>
<el-alert
v-if="lastClaimUrl"
:title="`最近可用领取链接:${lastClaimUrl}`"
type="info"
show-icon
:closable="false"
class="mt-4"
/>
<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 table-toolbar">
<strong>任务列表</strong>
<span> {{ pagination.page }} 当前展示 {{ items.length }} </span>
</div>
<el-table :data="items" stripe size="small" class="tasks-table data-table">
<el-table-column label="任务 / 订单" min-width="230">
<template #default="{ row }">
<div class="cell-stack">
<RouterLink
:to="`/admin/tasks/${row.taskId}`"
class="task-no id-link"
: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>
</div>
</template>
</el-table-column>
<el-table-column label="商品" min-width="210">
<template #default="{ row }">
<div class="cell-stack">
<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="250">
<template #default="{ row }">
<div class="task-status-summary">
<span class="task-main-chip" :data-tone="resolveTaskTone(row)">
{{ resolveTaskMainText(row) }}
</span>
<p class="task-status-note">
客户 {{ resolveCustomerStageLabel(row.customerStatus) }} · 资源
{{ resolveResourceStageLabel(row.resourceStatus) }}
</p>
<p v-if="row.roleName || row.roleId" class="task-status-note">
角色 {{ row.roleName || '-' }} / {{ row.roleId || '-' }}
</p>
</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'].includes(row.status)
"
:loading="actionLoadingId === row.taskId"
size="small"
type="danger"
plain
@click="handleActionCommand('retry', row)"
>
重试
</el-button>
<el-button
v-if="canRebindListTask(row)"
size="small"
@click="handleActionCommand('rebind_role', 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"
class="pagination-center"
@current-change="loadTasks"
/>
</template>
</el-card>
</div>
</template>
<style scoped>
@import '../../../styles/admin-list-pages.css';
.filter-grid {
grid-template-columns:
minmax(150px, 0.75fr) minmax(220px, 1fr) minmax(220px, 1fr)
minmax(210px, 0.95fr);
}
.filter-grid :deep(.el-date-editor) {
width: 100%;
}
.task-status-summary {
display: grid;
gap: 6px;
min-width: 0;
}
.task-main-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-height: 26px;
padding: 0 10px;
border-radius: var(--radius-full);
border: 1px solid transparent;
font-size: var(--text-xs);
font-weight: 800;
white-space: nowrap;
}
.task-main-chip[data-tone='success'] {
color: #0f766e;
background: var(--color-success-light);
border-color: #a6f4c5;
}
.task-main-chip[data-tone='warning'] {
color: #b54708;
background: var(--color-warning-light);
border-color: #fedf89;
}
.task-main-chip[data-tone='danger'] {
color: var(--color-danger-dark);
background: var(--color-danger-light);
border-color: #fecaca;
}
.task-status-note {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.5;
}
.task-status-note + .task-status-note {
color: var(--text-primary);
font-weight: 700;
}
.cell-error {
display: block;
min-width: 0;
line-height: 1.55;
color: #475467;
word-break: break-word;
font-size: var(--text-xs);
}
.cell-subline--strong {
color: var(--text-primary);
font-weight: 600;
}
.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;
}
</style>