496 lines
15 KiB
Vue
496 lines
15 KiB
Vue
<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>
|