Files
order_site/apps/frontend/src/views/admin/AdminTasksView.vue
T
2026-04-23 14:46:06 +08:00

836 lines
20 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 AdminPaginationBar from '@/components/admin/AdminPaginationBar.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>
<section class="admin-panel">
<header class="panel-header">
<div class="panel-header-copy">
<h1>交付任务</h1>
<p>更紧凑地查看任务状态、角色绑定、库存凭据和异常信息。</p>
</div>
<div class="panel-header-badge">共 {{ pagination.total }} 条任务</div>
</header>
<section class="filters-card">
<div class="filters-grid">
<label class="filter-field">
<span>状态</span>
<select v-model="status" class="text-input select-input">
<option v-for="option in adminTaskStatusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>任务号</span>
<input v-model="taskNo" class="text-input" placeholder="支持模糊匹配" />
</label>
<label class="filter-field">
<span>订单号</span>
<input v-model="platformOrderId" class="text-input" placeholder="平台订单号" />
</label>
<label class="filter-field">
<span>角色 ID</span>
<input v-model="roleId" class="text-input" placeholder="角色唯一标识" />
</label>
<label class="filter-field">
<span>SKU</span>
<input v-model="skuCode" class="text-input" placeholder="商品 SKU" />
</label>
<label class="filter-field">
<span>开始日期</span>
<input v-model="dateFrom" class="text-input" type="date" />
</label>
<label class="filter-field">
<span>结束日期</span>
<input v-model="dateTo" class="text-input" type="date" />
</label>
</div>
<div class="filters-actions">
<span class="filters-hint">桌面端已改为自适应卡片列表,不再出现横向滚动。</span>
<div class="filters-button-row">
<el-button round @click="resetFilters">重置</el-button>
<el-button round type="primary" @click="() => loadTasks(1)">查询</el-button>
</div>
</div>
</section>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
<p v-if="lastClaimUrl" class="info-copy">最近生成的领取链接:{{ lastClaimUrl }}</p>
<div v-if="loading" class="empty-block">任务列表加载中</div>
<div v-else-if="items.length === 0" class="empty-block">当前筛选条件下暂无任务</div>
<div v-else class="tasks-shell">
<div class="tasks-toolbar">
<div>
<strong>任务列表</strong>
<span>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span>
</div>
</div>
<div class="task-list">
<article v-for="item in items" :key="item.taskId" class="task-card">
<div class="task-card-top">
<div class="task-identity">
<RouterLink :to="`/admin/tasks/${item.taskId}`" class="cell-link task-no" :title="item.taskNo">
{{ item.taskNo }}
</RouterLink>
<div class="task-meta-row">
<span class="compact-chip" :title="item.platformOrderId">订单 {{ item.platformOrderId }}</span>
<span class="compact-chip compact-chip--muted">重试 {{ item.retryCount }} 次</span>
</div>
</div>
<AdminStatusTag :status="item.status" />
</div>
<div class="task-grid">
<section class="task-section">
<span class="section-label">任务进度</span>
<div class="progress-cell">
<div class="progress-row">
<span class="progress-label">任务</span>
<AdminStatusTag :status="item.status" />
</div>
<div class="progress-row">
<span class="progress-label">系统</span>
<AdminStatusTag :status="item.systemBindingStatus" />
</div>
<div class="progress-row">
<span class="progress-label">完整</span>
<AdminStatusTag :status="item.userBindingStatus" />
</div>
</div>
</section>
<section class="task-section">
<span class="section-label">商品 / 凭据</span>
<div class="goods-cell">
<span class="goods-title" :title="item.skuName || item.skuCode">{{ item.skuName || item.skuCode }}</span>
<span class="goods-subline">SKU {{ item.skuCode || '-' }}</span>
<span class="goods-subline" :title="item.inventoryDisplayMasked || '-'">
凭据 {{ item.inventoryDisplayMasked || '-' }}
</span>
<span class="goods-subline" :title="formatBindingRoles(item)">
绑定 {{ formatBindingRoles(item) }}
</span>
<span class="goods-subline goods-subline-strong" :title="formatBindingCountSummary(item)">
{{ formatBindingCountSummary(item) }}
</span>
</div>
</section>
<section class="task-section">
<span class="section-label">角色绑定</span>
<div class="role-cell">
<span class="role-title" :title="item.roleName || item.roleId || '-'">{{ item.roleName || '-' }}</span>
<span class="role-subline" :title="item.roleId || '-'">角色ID {{ item.roleId || '-' }}</span>
<span class="role-subline">登录 {{ item.loginType || '-' }}</span>
</div>
</section>
<section class="task-section">
<span class="section-label">时间</span>
<div class="time-cell">
<span class="time-line">创建 {{ formatAdminDateTime(item.createdAt) }}</span>
<span class="time-line">更新 {{ formatAdminDateTime(item.updatedAt) }}</span>
<span v-if="item.claimedAt" class="time-line">领取 {{ formatAdminDateTime(item.claimedAt) }}</span>
<span v-if="item.roleConfirmedAt" class="time-line">确认 {{ formatAdminDateTime(item.roleConfirmedAt) }}</span>
<span v-if="item.redeemedAt" class="time-line">兑换 {{ formatAdminDateTime(item.redeemedAt) }}</span>
</div>
</section>
<section class="task-section">
<span class="section-label">异常 / 操作</span>
<div class="task-end">
<span class="cell-error" :title="item.lastError || '-'">{{ item.lastError || '当前无异常' }}</span>
<div class="action-cell">
<el-button
v-if="canManageTaskLifecycle"
:disabled="item.executorKey === 'manual_dispatch' || !['retry_pending', 'manual_review', 'waiting_inventory'].includes(item.status)"
:loading="actionLoadingId === item.taskId"
class="primary-action"
plain
type="danger"
@click="runAction(item.taskId, () => retryAdminTask(item.taskId), '任务已重试', `确认重试任务 ${item.taskNo} 吗?`)"
>
重试
</el-button>
<el-button
v-if="canManageTaskLifecycle && item.executorKey !== 'manual_dispatch'"
class="secondary-action"
@click="handleActionCommand('regenerate_claim_link', item)"
>
重发链接
</el-button>
<el-button
v-if="canManageTaskLifecycle"
class="secondary-action"
@click="handleActionCommand('manual_review', item)"
>
转人工
</el-button>
<el-button
v-if="canCloseTasks && !['redeemed', 'closed'].includes(item.status)"
class="secondary-action secondary-action-danger"
@click="handleActionCommand('close', item)"
>
关闭任务
</el-button>
</div>
</div>
</section>
</div>
</article>
</div>
<AdminPaginationBar
:page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:loading="loading"
@change="loadTasks"
/>
</div>
</section>
</template>
<style scoped>
.admin-panel {
display: grid;
gap: 14px;
min-width: 0;
}
.panel-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.panel-header-copy {
min-width: 0;
}
.panel-header h1 {
margin: 0;
font-size: 34px;
line-height: 1.05;
letter-spacing: -0.04em;
color: #1d3555;
}
.panel-header p {
margin: 6px 0 0;
font-size: 14px;
color: #64748b;
}
.panel-header-badge {
flex: 0 0 auto;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(86, 108, 138, 0.12);
color: #38506e;
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
}
.filters-card,
.tasks-shell,
.empty-block,
.error-copy,
.info-copy {
padding: 16px 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
box-shadow: 0 14px 36px rgba(30, 49, 78, 0.06);
}
.filters-card {
display: grid;
gap: 14px;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.filter-field {
display: grid;
gap: 6px;
min-width: 0;
}
.filter-field span {
color: #5d6f87;
font-size: 12px;
font-weight: 600;
}
.filters-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.filters-hint {
color: #64748b;
font-size: 12px;
}
.filters-button-row {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.text-input {
width: 100%;
min-height: 38px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(86, 108, 138, 0.18);
background: rgba(255, 255, 255, 0.94);
color: #24364e;
font-size: 13px;
}
.select-input {
appearance: none;
}
.error-copy {
margin: 0;
color: #b42318;
}
.info-copy {
margin: 0;
color: #175cd3;
word-break: break-all;
}
.tasks-shell {
display: grid;
gap: 14px;
min-width: 0;
}
.tasks-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tasks-toolbar strong {
display: block;
color: #1f324a;
font-size: 14px;
}
.tasks-toolbar span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.task-list {
display: grid;
gap: 12px;
}
.task-card {
display: grid;
gap: 14px;
padding: 14px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.98) 0%, #ffffff 100%);
border: 1px solid rgba(86, 108, 138, 0.12);
}
.task-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.task-identity {
min-width: 0;
display: grid;
gap: 8px;
}
.task-no {
display: inline-block;
max-width: 100%;
font-size: 20px;
line-height: 1.1;
font-weight: 800;
color: #132238;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.compact-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
max-width: 100%;
padding: 0 10px;
border-radius: 999px;
background: #f4f7fb;
border: 1px solid rgba(86, 108, 138, 0.1);
color: #44556c;
font-size: 12px;
font-weight: 600;
}
.compact-chip--muted {
color: #6b7c93;
}
.task-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.task-section {
display: grid;
gap: 8px;
min-width: 0;
padding: 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(86, 108, 138, 0.08);
}
.section-label {
color: #71839b;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.progress-cell {
display: grid;
gap: 8px;
}
.progress-row {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.progress-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
min-height: 22px;
padding: 0 7px;
border-radius: 999px;
background: #f7f9fc;
border: 1px solid rgba(86, 108, 138, 0.12);
color: #64748b;
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.cell-link {
color: inherit;
text-decoration: none;
}
.cell-link:hover {
color: #175cd3;
}
.meta-cell,
.goods-cell,
.role-cell,
.time-cell {
display: grid;
gap: 5px;
min-width: 0;
}
.time-line {
min-width: 0;
color: #475467;
font-size: 12px;
line-height: 1.5;
}
.meta-title,
.goods-title,
.role-title {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #101828;
font-weight: 700;
}
.meta-subline,
.goods-subline,
.role-subline {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #475467;
font-size: 12px;
}
.meta-muted {
color: #98a2b3;
}
.goods-subline-strong {
color: #1d3555;
font-weight: 600;
}
.cell-error {
display: block;
min-width: 0;
line-height: 1.55;
color: #475467;
word-break: break-word;
font-size: 12px;
}
.task-end {
display: grid;
gap: 12px;
min-width: 0;
}
.action-cell {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.primary-action,
.secondary-action {
min-width: 82px;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
font-weight: 600;
font-size: 12px;
}
.primary-action {
background: #fff5f4;
border-color: #fda29b;
color: #d92d20;
}
.primary-action:hover,
.primary-action:focus {
background: #fee4e2;
border-color: #f97066;
color: #b42318;
}
.secondary-action {
background: #ffffff;
border-color: rgba(86, 108, 138, 0.18);
color: #344054;
}
.secondary-action:hover,
.secondary-action:focus {
border-color: rgba(23, 92, 211, 0.28);
color: #175cd3;
background: #f8fbff;
}
.secondary-action-danger {
border-color: rgba(217, 45, 32, 0.22);
color: #c43225;
background: #fff7f6;
}
.secondary-action-danger:hover,
.secondary-action-danger:focus {
border-color: rgba(217, 45, 32, 0.34);
color: #a62c21;
background: #feeceb;
}
.action-cell :deep(.el-button > span) {
white-space: nowrap;
}
@media (max-width: 1400px) {
.task-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 1180px) {
.task-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.task-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.task-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1400px) {
.filters-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.panel-header {
flex-direction: column;
align-items: flex-start;
}
.filters-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filters-actions {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 720px) {
.panel-header h1 {
font-size: 28px;
}
.filters-grid {
grid-template-columns: 1fr;
}
.filters-button-row,
.task-card-top,
.action-cell {
width: 100%;
}
.filters-button-row {
justify-content: stretch;
}
.filters-button-row :deep(.el-button),
.action-cell :deep(.el-button) {
flex: 1 1 auto;
}
.task-card-top {
flex-direction: column;
align-items: flex-start;
}
.task-no {
font-size: 18px;
}
}
</style>