512 lines
16 KiB
Vue
512 lines
16 KiB
Vue
<script setup lang="ts">
|
||
import { ref } from "vue";
|
||
|
||
import { useAdminAction } from "@/composables/useAdminAction";
|
||
import { useAdminListPage } from "@/composables/useAdminListPage";
|
||
import AdminPageHeader from "@/components/admin/AdminPageHeader.vue";
|
||
import AdminStatusTag from "@/components/admin/AdminStatusTag.vue";
|
||
import {
|
||
closeAdminTask,
|
||
fetchAdminTasks,
|
||
markAdminTaskManualReview,
|
||
regenerateAdminTaskClaimLink,
|
||
retryAdminTask,
|
||
} from "@/services/admin";
|
||
import type { AdminTaskListItem } from "@/types/admin";
|
||
import { hasAdminRole } from "@/utils/admin-auth";
|
||
import {
|
||
formatBindingRoleSummary,
|
||
formatBindingCountSummary,
|
||
} from "@/utils/admin-display";
|
||
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"
|
||
| "regenerate_claim_link"
|
||
| "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 === "regenerate_claim_link") {
|
||
void runAction({
|
||
id: item.taskId,
|
||
action: async () => {
|
||
const response = await regenerateAdminTaskClaimLink(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);
|
||
}
|
||
</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"
|
||
>按状态、任务号、订单号、角色标识、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"
|
||
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="220">
|
||
<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>
|
||
<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="
|
||
formatBindingRoleSummary(
|
||
row.bindingSummary.roleKeys,
|
||
'无绑定角色'
|
||
)
|
||
"
|
||
>绑定
|
||
{{
|
||
formatBindingRoleSummary(
|
||
row.bindingSummary.roleKeys,
|
||
"无绑定角色"
|
||
)
|
||
}}</span
|
||
>
|
||
<span
|
||
class="cell-subline cell-subline--strong"
|
||
:title="formatBindingCountSummary(row.bindingSummary)"
|
||
>{{ formatBindingCountSummary(row.bindingSummary) }}</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({
|
||
id: row.taskId,
|
||
action: async () => {
|
||
const response = await retryAdminTask(row.taskId);
|
||
if (response.data.claimUrl)
|
||
lastClaimUrl = response.data.claimUrl;
|
||
},
|
||
successMessage: '任务已重试',
|
||
confirmText: `确认重试任务 ${row.taskNo} 吗?`,
|
||
onAfterAction: async () => {
|
||
await loadTasks();
|
||
},
|
||
})
|
||
"
|
||
>
|
||
重试
|
||
</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"
|
||
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%;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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>
|