重命名前端目录
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.claim-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.section-card p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-card h3 {
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 26%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.96));
|
||||
}
|
||||
|
||||
.hero-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-copy h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.hero-inline span,
|
||||
.hero-link-row span {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-meta-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1px;
|
||||
background: var(--border-default);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-meta-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-metric {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 10px var(--space-4);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.hero-metric span {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-metric strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hero-inline,
|
||||
.hero-link-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auto-delivery-tag-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auto-delivery-note {
|
||||
margin-top: 14px;
|
||||
padding: 14px var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.auto-delivery-note p {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auto-delivery-note p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.redeem-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.redeem-attempt-card {
|
||||
padding: 14px var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.redeem-attempt-grid span {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card strong,
|
||||
.redeem-attempt-grid strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.redeem-attempts {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.redeem-attempt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.redeem-outcome {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.redeem-attempt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.redeem-attempt-copy {
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.binding-primary {
|
||||
margin-top: 4px;
|
||||
color: var(--color-success);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.binding-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.manual-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-panel :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-panel :deep(.el-form-item__label) {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.manual-hint {
|
||||
margin: 14px 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.manual-summary {
|
||||
margin-top: var(--space-4);
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.manual-summary p {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.manual-summary p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.flow-card {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(37, 99, 235, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96));
|
||||
}
|
||||
|
||||
.flow-card-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.flow-head-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.kuaishou-focus-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.flow-block {
|
||||
padding: var(--space-4);
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border-default);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.flow-block--spotlight {
|
||||
background:
|
||||
linear-gradient(160deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.95)),
|
||||
rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.flow-block h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.flow-block p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.flow-meta-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.flow-meta-list div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.flow-meta-list span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.flow-meta-list strong {
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.flow-meta-list--dense {
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.flow-meta-list--two-column {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.spotlight-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.spotlight-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.flow-copy {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.flow-collapse {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* ===== Flow Timeline (Checklist) ===== */
|
||||
.flow-timeline {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin-top: var(--space-4);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.flow-timeline-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: 0 var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Vertical connector line */
|
||||
.flow-timeline-item:not(:last-child) .timeline-gutter::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 24px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-default);
|
||||
}
|
||||
|
||||
.timeline-gutter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
background: var(--border-default);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.flow-timeline-item--done .timeline-dot {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.flow-timeline-item--current .timeline-dot {
|
||||
background: var(--text-primary);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.flow-timeline-item--error .timeline-dot {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
padding: 8px 0 14px;
|
||||
}
|
||||
|
||||
.timeline-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.flow-timeline-item--current .timeline-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-current-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.timeline-detail {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.flow-timeline-item--done .timeline-detail {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ===== Collapse Badge ===== */
|
||||
.collapse-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.collapse-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ===== Event Timeline ===== */
|
||||
.event-timeline {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.event-tl-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: 0 var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-tl-item:not(:last-child) .event-tl-gutter::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 24px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-default);
|
||||
}
|
||||
|
||||
.event-tl-gutter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.event-tl-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.event-tl-body {
|
||||
padding: 8px 0 14px;
|
||||
}
|
||||
|
||||
.event-tl-type {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.event-tl-payload {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.event-tl-time {
|
||||
margin-top: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* ===== Role Enhancements ===== */
|
||||
.role-verified-badge {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-pending-hint {
|
||||
margin-top: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.role-hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.role-hero div {
|
||||
padding: 14px var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.role-hero span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.role-hero strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.role-warning {
|
||||
margin: 14px 0 0;
|
||||
padding: var(--space-3) 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--color-warning-light);
|
||||
color: #b54708;
|
||||
}
|
||||
|
||||
.event-copy {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
margin-top: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.screenshot-link {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.screenshot-empty {
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.screenshot-preview {
|
||||
display: block;
|
||||
width: min(100%, 720px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border-default);
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-head,
|
||||
.hero-inline,
|
||||
.hero-link-row,
|
||||
.flow-card-head,
|
||||
.flow-head-actions,
|
||||
.spotlight-head,
|
||||
.spotlight-actions {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.timeline-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import {
|
||||
closeAdminTask,
|
||||
markAdminTaskManualReview,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
retryAdminTask,
|
||||
} from '@/services/admin'
|
||||
|
||||
import AdminTaskKuaishouCloudPanel from './components/AdminTaskKuaishouCloudPanel.vue'
|
||||
import AdminTaskActionBar from './components/AdminTaskActionBar.vue'
|
||||
import AdminTaskEventsSection from './components/AdminTaskEventsSection.vue'
|
||||
import AdminTaskHeroSection from './components/AdminTaskHeroSection.vue'
|
||||
import AdminTaskInfoSection from './components/AdminTaskInfoSection.vue'
|
||||
import AdminTaskManualDispatchSection from './components/AdminTaskManualDispatchSection.vue'
|
||||
import AdminTaskRedeemResolutionSection from './components/AdminTaskRedeemResolutionSection.vue'
|
||||
import AdminTaskScreenshotSection from './components/AdminTaskScreenshotSection.vue'
|
||||
import { useAdminTaskDetail } from './composables/useAdminTaskDetail'
|
||||
import { useAdminTaskKuaishouCloud } from './composables/useAdminTaskKuaishouCloud'
|
||||
|
||||
const {
|
||||
loading,
|
||||
actionLoading,
|
||||
errorMessage,
|
||||
detail,
|
||||
screenshotPreviewUrl,
|
||||
manualDispatchForm,
|
||||
canManageTaskLifecycle,
|
||||
canCloseTasks,
|
||||
canViewSensitiveTaskData,
|
||||
claimUrl,
|
||||
claimLinkInvalid,
|
||||
screenshotSectionTitle,
|
||||
loadDetail,
|
||||
runAction,
|
||||
submitManualDispatch,
|
||||
copyText,
|
||||
openExternalLink,
|
||||
} = useAdminTaskDetail()
|
||||
|
||||
const {
|
||||
kuaishouCloudFlow,
|
||||
isKuaishouCloudTask,
|
||||
summaryCards,
|
||||
kuaishouRoleInfoEntries,
|
||||
kuaishouCloudChecklist,
|
||||
submitKuaishouCloudPrepare,
|
||||
submitKuaishouCloudDispatch,
|
||||
submitKuaishouCloudRefreshRoleInfo,
|
||||
submitKuaishouCloudReturnNumber,
|
||||
} = useAdminTaskKuaishouCloud(detail, runAction)
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<AdminPageHeader title="任务详情" description="查看任务全量信息并执行人工操作。" />
|
||||
|
||||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" />
|
||||
|
||||
<el-skeleton v-if="loading" :rows="6" animated />
|
||||
|
||||
<template v-else-if="detail">
|
||||
<AdminTaskHeroSection
|
||||
:detail="detail"
|
||||
:is-kuaishou-cloud-task="isKuaishouCloudTask"
|
||||
:summary-cards="summaryCards"
|
||||
:claim-url="claimUrl"
|
||||
:claim-link-invalid="claimLinkInvalid"
|
||||
/>
|
||||
|
||||
<AdminTaskActionBar
|
||||
:detail="detail"
|
||||
:action-loading="actionLoading"
|
||||
:can-manage-task-lifecycle="canManageTaskLifecycle"
|
||||
:can-close-tasks="canCloseTasks"
|
||||
@retry="
|
||||
runAction(
|
||||
() => retryAdminTask(detail!.task.taskId),
|
||||
'任务已重试',
|
||||
`确认重试任务 ${detail!.task.taskNo} 吗?`,
|
||||
)
|
||||
"
|
||||
@rebind-role="
|
||||
runAction(
|
||||
() => rebindAdminTaskKuaishouCloudRole(detail!.task.taskId),
|
||||
'角色换绑资源已准备完成',
|
||||
`确认为任务 ${detail!.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||
)
|
||||
"
|
||||
@prepare-kuaishou-cloud="submitKuaishouCloudPrepare"
|
||||
@dispatch-kuaishou-cloud="submitKuaishouCloudDispatch"
|
||||
@return-kuaishou-cloud="submitKuaishouCloudReturnNumber"
|
||||
@mark-manual-review="
|
||||
runAction(
|
||||
() => markAdminTaskManualReview(detail!.task.taskId),
|
||||
'任务已转人工处理',
|
||||
`确认将任务 ${detail!.task.taskNo} 转为人工处理吗?`,
|
||||
)
|
||||
"
|
||||
@submit-manual-dispatch="submitManualDispatch"
|
||||
@close-task="
|
||||
runAction(
|
||||
() => closeAdminTask(detail!.task.taskId),
|
||||
'任务已关闭',
|
||||
`确认关闭任务 ${detail!.task.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<AdminTaskInfoSection
|
||||
v-if="!isKuaishouCloudTask"
|
||||
:detail="detail"
|
||||
:can-view-sensitive-task-data="canViewSensitiveTaskData"
|
||||
:claim-url="claimUrl"
|
||||
:claim-link-invalid="claimLinkInvalid"
|
||||
/>
|
||||
|
||||
<AdminTaskKuaishouCloudPanel
|
||||
v-if="isKuaishouCloudTask && kuaishouCloudFlow"
|
||||
:detail="detail"
|
||||
:flow="kuaishouCloudFlow"
|
||||
:role-info-entries="kuaishouRoleInfoEntries"
|
||||
:checklist="kuaishouCloudChecklist"
|
||||
:claim-url="claimUrl"
|
||||
:action-loading="actionLoading"
|
||||
@refresh-role="submitKuaishouCloudRefreshRoleInfo"
|
||||
@copy-bind-url="copyText($event, '绑定链接已复制')"
|
||||
@open-bind-url="openExternalLink"
|
||||
/>
|
||||
|
||||
<AdminTaskRedeemResolutionSection v-if="detail.redeemResolution" :detail="detail" />
|
||||
|
||||
<AdminTaskManualDispatchSection
|
||||
v-if="detail.task.executorKey === 'manual_dispatch'"
|
||||
:detail="detail"
|
||||
:form="manualDispatchForm"
|
||||
:can-manage-task-lifecycle="canManageTaskLifecycle"
|
||||
:action-loading="actionLoading"
|
||||
@update:form="Object.assign(manualDispatchForm, $event)"
|
||||
@submit="submitManualDispatch"
|
||||
/>
|
||||
|
||||
<AdminTaskEventsSection v-if="detail.events.length > 0" :detail="detail" />
|
||||
|
||||
<AdminTaskScreenshotSection
|
||||
v-if="detail.screenshotUrl"
|
||||
:screenshot-preview-url="screenshotPreviewUrl"
|
||||
:section-title="screenshotSectionTitle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<el-empty v-else description="任务不存在或已被删除" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,495 @@
|
||||
<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>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
actionLoading: boolean
|
||||
canManageTaskLifecycle: boolean
|
||||
canCloseTasks: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
rebindRole: []
|
||||
prepareKuaishouCloud: []
|
||||
dispatchKuaishouCloud: []
|
||||
returnKuaishouCloud: []
|
||||
markManualReview: []
|
||||
submitManualDispatch: [outcome: 'delivered' | 'failed']
|
||||
closeTask: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="action-row">
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
:disabled="!detail.operations.canRetry"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
重试任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canRebindKuaishouCloudRole"
|
||||
:disabled="!detail.operations.canRebindKuaishouCloudRole"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('rebindRole')"
|
||||
>
|
||||
换绑角色
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canPrepareKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('prepareKuaishouCloud')"
|
||||
>
|
||||
准备绑定资源
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canDispatchKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('dispatchKuaishouCloud')"
|
||||
>
|
||||
确认绑定完成并发货
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canReturnKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="warning"
|
||||
@click="emit('returnKuaishouCloud')"
|
||||
>
|
||||
退还号码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
:disabled="!detail.operations.canMarkManualReview"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('markManualReview')"
|
||||
>
|
||||
转人工
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('submitManualDispatch', 'delivered')"
|
||||
>
|
||||
人工完成
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
plain
|
||||
@click="emit('submitManualDispatch', 'failed')"
|
||||
>
|
||||
履约失败
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canCloseTasks"
|
||||
:disabled="!detail.operations.canClose"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
@click="emit('closeTask')"
|
||||
>
|
||||
关闭任务
|
||||
</el-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatTaskEventPayload, formatTaskEventType } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>任务事件</h3>
|
||||
<div class="event-timeline">
|
||||
<div
|
||||
v-for="event in detail.events"
|
||||
:key="event.eventType + event.createdAt"
|
||||
class="event-tl-item"
|
||||
>
|
||||
<div class="event-tl-gutter">
|
||||
<div class="event-tl-dot"></div>
|
||||
</div>
|
||||
<div class="event-tl-body">
|
||||
<div class="event-tl-type">{{ formatTaskEventType(event.eventType) }}</div>
|
||||
<div class="event-tl-payload">{{ formatTaskEventPayload(event.payload) }}</div>
|
||||
<div class="event-tl-time">{{ formatAdminDateTime(event.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
import type { SummaryCard } from '../composables/types'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
isKuaishouCloudTask: boolean
|
||||
summaryCards: SummaryCard[]
|
||||
claimUrl: string
|
||||
claimLinkInvalid: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
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 || '-'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Kuaishou Cloud hero card -->
|
||||
<el-card
|
||||
v-if="isKuaishouCloudTask && detail.kuaishouCloudFulfillment"
|
||||
shadow="never"
|
||||
class="section-card hero-card"
|
||||
>
|
||||
<div class="hero-head">
|
||||
<div class="hero-copy">
|
||||
<span class="hero-eyebrow">自动履约任务</span>
|
||||
<h2>{{ detail.task.taskNo }}</h2>
|
||||
<p>
|
||||
订单 {{ detail.order?.platformOrderId || '-' }} · 商品
|
||||
{{ detail.orderItem?.skuName || detail.orderItem?.skuCode || '-' }}
|
||||
· 核销店铺
|
||||
{{ detail.order?.shopName || detail.order?.shopId || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="status-stack">
|
||||
<AdminStatusTag :status="detail.task.status" :show-raw="false" />
|
||||
<AdminStatusTag :status="detail.order?.payStatus || ''" :show-raw="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-meta-grid">
|
||||
<el-card
|
||||
v-for="card in summaryCards"
|
||||
:key="card.label"
|
||||
shadow="never"
|
||||
class="hero-metric"
|
||||
>
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.value }}</strong>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="hero-inline">
|
||||
<span>资源 {{ resolveResourceStageLabel(detail.task.resourceStatus) }}</span>
|
||||
<span>客户 {{ resolveCustomerStageLabel(detail.task.customerStatus) }}</span>
|
||||
<span v-if="detail.task.lastError">最近异常 {{ detail.task.lastError }}</span>
|
||||
</div>
|
||||
<div v-if="claimUrl" class="hero-link-row">
|
||||
<span>客户领取页</span>
|
||||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
</div>
|
||||
<div v-else-if="claimLinkInvalid" class="hero-link-row">
|
||||
<span>客户领取页</span>
|
||||
<strong>已失效</strong>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Simple info card for other task types -->
|
||||
<el-card v-else shadow="never" class="section-card">
|
||||
<h2>{{ detail.task.taskNo }}</h2>
|
||||
<p>
|
||||
状态:<AdminStatusTag :status="detail.task.status" /> · 订单:{{
|
||||
detail.order?.platformOrderId || '-'
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
执行器:{{ detail.task.executorKey || '-' }} · 履约:<AdminStatusTag
|
||||
:status="detail.task.deliveryStatus"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
资源:{{ resolveResourceStageLabel(detail.task.resourceStatus) }} ·
|
||||
客户:{{ resolveCustomerStageLabel(detail.task.customerStatus) }}
|
||||
</p>
|
||||
<p>
|
||||
角色:{{ detail.task.roleName || '-' }} /
|
||||
{{ detail.task.roleId || '-' }}
|
||||
</p>
|
||||
<p>最后错误:{{ detail.task.lastError || '-' }}</p>
|
||||
<p v-if="claimUrl">
|
||||
领取链接:
|
||||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
</p>
|
||||
<p v-else-if="claimLinkInvalid">领取链接:已失效</p>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
canViewSensitiveTaskData: boolean
|
||||
claimUrl: string
|
||||
claimLinkInvalid: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>任务信息</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="商品">{{
|
||||
detail.orderItem?.skuName || '-'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="SKU">{{
|
||||
detail.orderItem?.skuCode || '-'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务执行器">
|
||||
{{ detail.task.executorKey || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="履约状态">
|
||||
<AdminStatusTag :status="detail.task.deliveryStatus || ''" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结果代码">{{
|
||||
detail.task.resultCode || '-'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果说明">
|
||||
{{ detail.task.resultMessage || '-' }}
|
||||
</el-descriptions-item>
|
||||
<template v-if="canViewSensitiveTaskData">
|
||||
<el-descriptions-item label="Claim Token">
|
||||
{{ detail.claimToken?.token || '-' }}
|
||||
</el-descriptions-item>
|
||||
</template>
|
||||
<el-descriptions-item v-else label="Claim Token" :span="2">
|
||||
客服账号不可查看原始领取 Token
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Token 状态">
|
||||
<AdminStatusTag :status="detail.claimToken?.status || ''" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Token 过期">
|
||||
{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="领取链接" :span="2">
|
||||
<a v-if="claimUrl" class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{
|
||||
claimUrl
|
||||
}}</a>
|
||||
<span v-else-if="claimLinkInvalid">已失效</span>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="运行上下文">
|
||||
{{ detail.task.runtimeSessionId || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户打开链接">
|
||||
{{ formatAdminDateTime(detail.task.claimedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="客户提交">
|
||||
{{ formatAdminDateTime(detail.task.roleConfirmedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="履约完成">
|
||||
{{ formatAdminDateTime(detail.task.redeemedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="截图路径" :span="2">
|
||||
{{ detail.task.screenshotPath || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type KuaishouCloudFlow = NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
|
||||
type RoleInfoEntry = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
type ChecklistItem = {
|
||||
key: string
|
||||
label: string
|
||||
status: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
flow: KuaishouCloudFlow
|
||||
roleInfoEntries: RoleInfoEntry[]
|
||||
checklist: ChecklistItem[]
|
||||
claimUrl: string
|
||||
actionLoading: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const collapsePanels: string[] = []
|
||||
|
||||
const emit = defineEmits<{
|
||||
refreshRole: []
|
||||
copyBindUrl: [url: string]
|
||||
openBindUrl: [url: string]
|
||||
}>()
|
||||
|
||||
function formatCloudSourcePriority(flow: KuaishouCloudFlow) {
|
||||
return formatAccountNames(flow.binding.cloudSourceLabels, flow.binding.cloudSourceKeys)
|
||||
}
|
||||
|
||||
function formatResolvedCloudSource(flow: KuaishouCloudFlow) {
|
||||
return flow.binding.resolvedSourceLabel || flow.binding.resolvedSourceKey || '-'
|
||||
}
|
||||
|
||||
function formatAccountNames(labels: string[] | undefined, keys: string[] | undefined) {
|
||||
const displayNames = Array.isArray(labels)
|
||||
? labels.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
if (displayNames.length > 0) {
|
||||
return displayNames.join(' / ')
|
||||
}
|
||||
|
||||
const fallbackKeys = Array.isArray(keys)
|
||||
? keys.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
return fallbackKeys.length > 0 ? fallbackKeys.join(' / ') : '-'
|
||||
}
|
||||
|
||||
/** 根据 status 决定每个流程步骤的 modifier class 和图标 */
|
||||
function resolveTimelineItemClass(status: string, isCurrent: boolean) {
|
||||
if (isCurrent) return 'flow-timeline-item--current'
|
||||
const s = String(status || '').trim()
|
||||
if (['verified', 'ready', 'completed', 'done'].includes(s)) return 'flow-timeline-item--done'
|
||||
if (['error', 'failed', 'exception', 'resource_exception'].includes(s))
|
||||
return 'flow-timeline-item--error'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 计算 checklist 中第一个「非完成」的步骤索引,即当前卡点 */
|
||||
function findCurrentStepIndex(checklist: ChecklistItem[]) {
|
||||
const doneStatuses = ['verified', 'ready', 'completed', 'done']
|
||||
const idx = checklist.findIndex(
|
||||
(item) => !doneStatuses.includes(String(item.status || '').trim()),
|
||||
)
|
||||
return idx
|
||||
}
|
||||
|
||||
/** Collapse badge 状态推导 */
|
||||
function resolveClaimBadge(flow: KuaishouCloudFlow, claimUrl: string) {
|
||||
if (flow.ticket.verifiedAt && flow.binding.bindPreparedAt) return 'ok'
|
||||
if (claimUrl) return 'pending'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function resolveTicketBadge(flow: KuaishouCloudFlow) {
|
||||
if (flow.ticket.verifiedAt) return 'ok'
|
||||
if (flow.ticket.code) return 'warn'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function resolveCloudBadge(flow: KuaishouCloudFlow) {
|
||||
if (flow.dispatch.dispatchAt) return 'ok'
|
||||
if (flow.binding.resolvedSourceKey) return 'pending'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function resolveClosureBadge(flow: KuaishouCloudFlow) {
|
||||
if (flow.consume.consumedAt) return 'ok'
|
||||
if (flow.consume.errorMessage) return 'warn'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function badgeLabel(tone: string) {
|
||||
if (tone === 'ok') return '已完成'
|
||||
if (tone === 'warn') return '需关注'
|
||||
return '待处理'
|
||||
}
|
||||
|
||||
const roleHasIdentity = (flow: KuaishouCloudFlow) => !!(flow.role?.name || flow.role?.rid)
|
||||
|
||||
function formatRebindSource(source: string) {
|
||||
const normalized = String(source || '').trim()
|
||||
if (normalized.includes('claim_page')) return '客户自助'
|
||||
if (normalized.includes('admin')) return '后台操作'
|
||||
return normalized || '-'
|
||||
}
|
||||
|
||||
function formatRebindBinding(
|
||||
binding: { vnId?: number; vnPhone?: string; roleName?: string; roleId?: string } | null,
|
||||
) {
|
||||
if (!binding) return '-'
|
||||
|
||||
const vn = binding.vnId ? `虚拟号 ${binding.vnId}` : '虚拟号 -'
|
||||
const phone = binding.vnPhone ? ` / ${binding.vnPhone}` : ''
|
||||
const role =
|
||||
binding.roleName || binding.roleId
|
||||
? `;角色 ${binding.roleName || '-'} / ${binding.roleId || '-'}`
|
||||
: ''
|
||||
|
||||
return `${vn}${phone}${role}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card flow-card">
|
||||
<div class="flow-card-head">
|
||||
<div>
|
||||
<h3>自动履约流程</h3>
|
||||
<p class="section-copy">
|
||||
优先看当前步骤和客户角色;详细链接、核销、cloud 资源字段可按需展开。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flow-head-actions">
|
||||
<el-button
|
||||
v-if="detail.operations.canRefreshKuaishouCloudRoleInfo"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('refreshRole')"
|
||||
>
|
||||
刷新角色信息
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="flow.binding.bindUrl"
|
||||
round
|
||||
@click="emit('copyBindUrl', flow.binding.bindUrl)"
|
||||
>
|
||||
复制绑定链接
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="flow.binding.bindUrl"
|
||||
round
|
||||
type="primary"
|
||||
plain
|
||||
@click="emit('openBindUrl', flow.binding.bindUrl)"
|
||||
>
|
||||
打开绑定页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kuaishou-focus-grid">
|
||||
<!-- 客户角色卡片 -->
|
||||
<section class="flow-block flow-block--spotlight">
|
||||
<div class="spotlight-head">
|
||||
<div>
|
||||
<h4>客户角色</h4>
|
||||
<p class="flow-copy">客户绑定后刷新角色信息,确认无误后再执行发货。</p>
|
||||
</div>
|
||||
<div class="spotlight-actions">
|
||||
<AdminStatusTag :status="flow.role?.status || 'pending'" />
|
||||
<el-button
|
||||
v-if="detail.operations.canRefreshKuaishouCloudRoleInfo"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
size="small"
|
||||
@click="emit('refreshRole')"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-hero">
|
||||
<div>
|
||||
<span>角色名</span>
|
||||
<strong>{{ flow.role?.name || detail.task.roleName || '等待识别' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>角色 ID</span>
|
||||
<strong>{{ flow.role?.rid || detail.task.roleId || '等待识别' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近刷新</span>
|
||||
<strong>{{ formatAdminDateTime(flow.role?.refreshedAt) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 已识别徽章 -->
|
||||
<div v-if="roleHasIdentity(flow)" class="role-verified-badge">✓ 角色已识别</div>
|
||||
<!-- 等待识别引导 -->
|
||||
<div v-else class="role-pending-hint">
|
||||
客户完成绑定后,点击右上角「刷新」按钮获取角色信息,确认角色名与 ID 无误后再执行发货。
|
||||
</div>
|
||||
<p v-if="flow.role?.errorMessage" class="role-warning">
|
||||
{{ flow.role.errorMessage }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 流程进度 Timeline -->
|
||||
<section class="flow-block flow-block--checklist">
|
||||
<h4>流程进度</h4>
|
||||
<p class="flow-copy">当前任务只需要按这个顺序推进,异常时再展开下方明细。</p>
|
||||
<div class="flow-timeline">
|
||||
<div
|
||||
v-for="(item, idx) in checklist"
|
||||
:key="item.key"
|
||||
class="flow-timeline-item"
|
||||
:class="resolveTimelineItemClass(item.status, idx === findCurrentStepIndex(checklist))"
|
||||
>
|
||||
<div class="timeline-gutter">
|
||||
<div class="timeline-dot"></div>
|
||||
</div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-head">
|
||||
<span class="timeline-label">{{ item.label }}</span>
|
||||
<span v-if="idx === findCurrentStepIndex(checklist)" class="timeline-current-badge"
|
||||
>当前</span
|
||||
>
|
||||
</div>
|
||||
<div class="timeline-detail">{{ item.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-collapse :model-value="collapsePanels" class="flow-collapse">
|
||||
<el-collapse-item name="claim">
|
||||
<template #title>
|
||||
<span class="collapse-title-wrap">
|
||||
客户领取与绑定明细
|
||||
<span class="collapse-badge">
|
||||
{{ badgeLabel(resolveClaimBadge(flow, claimUrl)) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div>
|
||||
<span>客户链接</span><strong>{{ claimUrl || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>绑定链接</span><strong>{{ flow.binding.bindUrl || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Token 状态</span><strong>{{ detail.claimToken?.status || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>准备时间</span
|
||||
><strong>{{ formatAdminDateTime(flow.binding.bindPreparedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>首次提交</span><strong>{{ formatAdminDateTime(flow.ticket.capturedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>校验时间</span><strong>{{ formatAdminDateTime(flow.ticket.verifiedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>虚拟号 ID</span><strong>{{ flow.binding.vnId || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>虚拟号码</span><strong>{{ flow.binding.vnPhone || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="manual-hint">
|
||||
客户会先在领取页查看图文指引并提交核销码,只有校验通过后才会自动跳转绑定链接。
|
||||
</p>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item name="ticket">
|
||||
<template #title>
|
||||
<span class="collapse-title-wrap">
|
||||
核销与商品明细
|
||||
<span class="collapse-badge">
|
||||
{{ badgeLabel(resolveTicketBadge(flow)) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div>
|
||||
<span>核销码</span><strong>{{ flow.ticket.code || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>校验状态</span><strong>{{ flow.ticket.status || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>商品标题</span><strong>{{ flow.ticket.goodsTitle || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>剩余次数</span><strong>{{ flow.ticket.leftCount || 0 }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="manual-hint">
|
||||
"确认绑定完成并发货"已经合并为一个按钮,只有核销码通过校验后才能继续。
|
||||
</p>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item name="cloud">
|
||||
<template #title>
|
||||
<span class="collapse-title-wrap">
|
||||
Cloud 资源与发货明细
|
||||
<span class="collapse-badge">
|
||||
{{ badgeLabel(resolveCloudBadge(flow)) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div>
|
||||
<span>账号优先级</span>
|
||||
<strong>{{ formatCloudSourcePriority(flow) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>实际使用账号</span>
|
||||
<strong>{{ formatResolvedCloudSource(flow) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>cloud SKU</span>
|
||||
<strong>{{
|
||||
flow.binding.skuId ? `${flow.binding.skuId} / ${flow.binding.skuName || '-'}` : '-'
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>VN Key</span><strong>{{ flow.binding.vnKey || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动购买</span
|
||||
><strong>{{ flow.purchase.autoBuyEnabled ? '开启' : '关闭' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>背包命中</span><strong>{{ flow.purchase.usedKnapsack ? '是' : '否' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>触发购买</span
|
||||
><strong>{{ flow.purchase.purchaseTriggered ? '是' : '否' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>购买时间</span
|
||||
><strong>{{ formatAdminDateTime(flow.purchase.purchaseAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发货时间</span
|
||||
><strong>{{ formatAdminDateTime(flow.dispatch.dispatchAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送类型</span><strong>{{ flow.dispatch.sendType || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>结果说明</span
|
||||
><strong>{{ flow.dispatch.note || detail.task.resultMessage || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item name="closure">
|
||||
<template #title>
|
||||
<span class="collapse-title-wrap">
|
||||
流程收口
|
||||
<span class="collapse-badge">
|
||||
{{ badgeLabel(resolveClosureBadge(flow)) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div>
|
||||
<span>退号时间</span
|
||||
><strong>{{ formatAdminDateTime(flow.returnNumber.returnedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动退号</span
|
||||
><strong>{{ flow.returnNumber.autoReturnEnabled ? '开启' : '关闭' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动核销</span
|
||||
><strong>{{ flow.consume.autoConsumeEnabled ? '开启' : '关闭' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>核销店铺</span><strong>{{ flow.consume.shopId || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>核销店铺名</span><strong>{{ flow.consume.shopName || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>核销时间</span><strong>{{ formatAdminDateTime(flow.consume.consumedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>核销异常</span><strong>{{ flow.consume.errorMessage || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>备注</span><strong>{{ flow.notes || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item v-if="roleInfoEntries.length > 0" name="raw" title="角色原始字段">
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div v-for="entry in roleInfoEntries" :key="`raw-${entry.key}`">
|
||||
<span>{{ entry.label }}</span>
|
||||
<strong>{{ entry.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item v-if="flow.rebind?.history?.length" name="rebind" title="换绑记录">
|
||||
<div class="event-timeline compact-event-timeline">
|
||||
<div
|
||||
v-for="item in flow.rebind.history"
|
||||
:key="`rebind-${item.attempt}-${item.requestedAt}`"
|
||||
class="event-tl-item"
|
||||
>
|
||||
<div class="event-tl-gutter">
|
||||
<div class="event-tl-dot"></div>
|
||||
</div>
|
||||
<div class="event-tl-body">
|
||||
<div class="event-tl-type">
|
||||
第 {{ item.attempt }} 次换绑 · {{ formatRebindSource(item.source) }} ·
|
||||
{{ item.status === 'success' ? '成功' : '失败' }}
|
||||
</div>
|
||||
<div class="event-tl-payload">原 {{ formatRebindBinding(item.oldBinding) }}</div>
|
||||
<div v-if="item.newBinding" class="event-tl-payload">
|
||||
新 {{ formatRebindBinding(item.newBinding) }}
|
||||
</div>
|
||||
<div v-if="item.errorMessage" class="event-tl-payload">
|
||||
{{ item.errorMessage }}
|
||||
</div>
|
||||
<div class="event-tl-time">{{ formatAdminDateTime(item.requestedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
import type { ManualDispatchForm } from '../composables/types'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
form: ManualDispatchForm
|
||||
canManageTaskLifecycle: boolean
|
||||
actionLoading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:form': [form: ManualDispatchForm]
|
||||
submit: [outcome: 'delivered' | 'failed']
|
||||
}>()
|
||||
|
||||
function updateField(field: keyof ManualDispatchForm, value: string) {
|
||||
emit('update:form', { ...props.form, [field]: value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>人工履约回写</h3>
|
||||
<el-form label-position="top" class="manual-grid">
|
||||
<el-form-item label="外部流水 / 单号">
|
||||
<el-input
|
||||
:model-value="form.deliveryReference"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="120"
|
||||
placeholder="例如快递单号、平台消息回执号"
|
||||
@update:model-value="updateField('deliveryReference', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="已交付内容">
|
||||
<el-input
|
||||
:model-value="form.deliveredCredential"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="500"
|
||||
placeholder="可填写人工发送的卡密、链接或关键信息"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
@update:model-value="updateField('deliveredCredential', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理备注">
|
||||
<el-input
|
||||
:model-value="form.resultMessage"
|
||||
:disabled="!canManageTaskLifecycle"
|
||||
maxlength="500"
|
||||
placeholder="说明实际履约结果,失败时建议写清原因"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
@update:model-value="updateField('resultMessage', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert
|
||||
title="回写后会直接更新任务终态,不再走旧式领取链接流程。"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
<el-descriptions v-if="detail.manualDispatch" :column="2" border class="manual-summary">
|
||||
<el-descriptions-item label="最近回写">
|
||||
<AdminStatusTag :status="detail.manualDispatch.outcome || detail.task.deliveryStatus" />
|
||||
· {{ formatAdminDateTime(detail.manualDispatch.completedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理人">
|
||||
{{ detail.manualDispatch.completedBy?.username || '-' }} /
|
||||
{{ detail.manualDispatch.completedBy?.role || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="流水号">
|
||||
{{ detail.manualDispatch.deliveryReference || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="交付内容">
|
||||
{{ detail.manualDispatch.deliveredCredential || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ detail.manualDispatch.resultMessage || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatRedeemOutcomeLabel, formatRedeemResolutionStatus } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>兑换重试链路</h3>
|
||||
<p class="section-copy">
|
||||
这里记录自动兑换时每次使用过的 CDK、判定结果,以及是否切换到了新的同类型凭据。
|
||||
</p>
|
||||
|
||||
<div class="redeem-summary-grid">
|
||||
<el-card shadow="never" class="metric-card">
|
||||
<span>处理结果</span>
|
||||
<strong>{{ formatRedeemResolutionStatus(detail.redeemResolution!.status) }}</strong>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="metric-card">
|
||||
<span>任务收口状态</span>
|
||||
<strong>{{ detail.redeemResolution!.taskStatus || detail.task.status || '-' }}</strong>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="metric-card">
|
||||
<span>更换新码次数</span>
|
||||
<strong>{{ detail.redeemResolution!.replacementCount }}</strong>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="metric-card">
|
||||
<span>完成时间</span>
|
||||
<strong>{{ formatAdminDateTime(detail.redeemResolution!.finishedAt) }}</strong>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.redeemResolution!.attempts.length > 0" class="redeem-attempts">
|
||||
<el-card
|
||||
shadow="never"
|
||||
v-for="attempt in detail.redeemResolution!.attempts"
|
||||
:key="attempt.attempt"
|
||||
class="redeem-attempt-card"
|
||||
>
|
||||
<div class="redeem-attempt-head">
|
||||
<strong>第 {{ attempt.attempt }} 次尝试</strong>
|
||||
<span class="redeem-outcome">{{ formatRedeemOutcomeLabel(attempt.outcome) }}</span>
|
||||
</div>
|
||||
<div class="redeem-attempt-grid">
|
||||
<div>
|
||||
<span>凭据</span>
|
||||
<strong>{{ attempt.codeMasked || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>类型</span>
|
||||
<strong>{{ attempt.credentialType || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>结果码</span>
|
||||
<strong>{{ attempt.resultCode || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="redeem-attempt-copy">
|
||||
{{ attempt.resultMessage || '-' }}
|
||||
</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
screenshotPreviewUrl: string
|
||||
sectionTitle: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>{{ sectionTitle }}</h3>
|
||||
<p v-if="!screenshotPreviewUrl" class="screenshot-empty">截图加载中或当前不可读取。</p>
|
||||
<a v-else class="screenshot-link" :href="screenshotPreviewUrl" target="_blank" rel="noreferrer"
|
||||
>打开原图</a
|
||||
>
|
||||
<img
|
||||
v-if="screenshotPreviewUrl"
|
||||
class="screenshot-preview"
|
||||
:src="screenshotPreviewUrl"
|
||||
:alt="sectionTitle"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
export type KuaishouCloudFlow = NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
|
||||
|
||||
export type SummaryCard = {
|
||||
label: string
|
||||
value: string
|
||||
tone: 'blue' | 'green' | 'amber' | 'slate'
|
||||
}
|
||||
|
||||
export type RoleInfoEntry = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type ChecklistItem = {
|
||||
key: string
|
||||
label: string
|
||||
status: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type ManualDispatchForm = {
|
||||
deliveryReference: string
|
||||
deliveredCredential: string
|
||||
resultMessage: string
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
completeAdminTaskManualDispatch,
|
||||
fetchAdminTaskScreenshot,
|
||||
fetchAdminTaskDetail,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
import type { ManualDispatchForm } from './types'
|
||||
|
||||
export function useAdminTaskDetail() {
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<AdminTaskDetail | null>(null)
|
||||
const lastClaimUrl = ref('')
|
||||
const screenshotPreviewUrl = ref('')
|
||||
const manualDispatchForm = ref<ManualDispatchForm>({
|
||||
deliveryReference: '',
|
||||
deliveredCredential: '',
|
||||
resultMessage: '',
|
||||
})
|
||||
|
||||
const canManageTaskLifecycle = computed(() => hasAdminRole('operator'))
|
||||
const canCloseTasks = computed(() => hasAdminRole('support'))
|
||||
const canViewSensitiveTaskData = computed(() =>
|
||||
Boolean(detail.value?.operations.canViewSensitiveTaskData),
|
||||
)
|
||||
|
||||
const claimUrl = computed(() => {
|
||||
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
|
||||
const taskStatus = String(detail.value?.task.status || '').trim()
|
||||
|
||||
if (tokenStatus !== 'active' || ['closed', 'expired'].includes(taskStatus)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return lastClaimUrl.value || detail.value?.claimToken?.claimUrl || ''
|
||||
})
|
||||
|
||||
const claimLinkInvalid = computed(() => {
|
||||
const tokenStatus = String(detail.value?.claimToken?.status || '').trim()
|
||||
return Boolean(
|
||||
detail.value?.claimToken?.claimUrl &&
|
||||
tokenStatus &&
|
||||
tokenStatus !== 'active',
|
||||
)
|
||||
})
|
||||
|
||||
const screenshotSectionTitle = computed(() => {
|
||||
if (detail.value?.task.status === 'redeemed') {
|
||||
return '结果截图'
|
||||
}
|
||||
|
||||
return detail.value?.review.required ? '客服复核截图' : '结果截图'
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
|
||||
detail.value = response.data
|
||||
syncManualDispatchForm(response.data)
|
||||
await loadScreenshotPreview(response.data)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) {
|
||||
try {
|
||||
await showConfirm(confirmText, '确认操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await action()
|
||||
if (response.data.claimUrl) {
|
||||
lastClaimUrl.value = response.data.claimUrl
|
||||
}
|
||||
showSuccess(successMessage)
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScreenshotPreview(taskDetail: AdminTaskDetail) {
|
||||
clearScreenshotPreview()
|
||||
|
||||
if (!taskDetail.screenshotUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await fetchAdminTaskScreenshot(taskDetail.task.taskId)
|
||||
screenshotPreviewUrl.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
screenshotPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearScreenshotPreview() {
|
||||
if (screenshotPreviewUrl.value) {
|
||||
URL.revokeObjectURL(screenshotPreviewUrl.value)
|
||||
screenshotPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
|
||||
manualDispatchForm.value = {
|
||||
deliveryReference: taskDetail.manualDispatch?.deliveryReference || '',
|
||||
deliveredCredential: taskDetail.manualDispatch?.deliveredCredential || '',
|
||||
resultMessage:
|
||||
taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
|
||||
const confirmText =
|
||||
outcome === 'failed'
|
||||
? `确认把任务 ${detail.value.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
|
||||
: `确认把任务 ${detail.value.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
|
||||
|
||||
await runAction(
|
||||
() =>
|
||||
completeAdminTaskManualDispatch(detail.value!.task.taskId, {
|
||||
outcome,
|
||||
resultMessage: manualDispatchForm.value.resultMessage,
|
||||
deliveryReference: manualDispatchForm.value.deliveryReference,
|
||||
deliveredCredential: manualDispatchForm.value.deliveredCredential,
|
||||
}),
|
||||
actionLabel,
|
||||
confirmText,
|
||||
)
|
||||
}
|
||||
|
||||
async function copyText(value: string, successMessage: string) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
showError('当前没有可复制的内容')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess(successMessage)
|
||||
} catch {
|
||||
showError('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalLink(value: string) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
window.open(text, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearScreenshotPreview)
|
||||
|
||||
return {
|
||||
loading,
|
||||
actionLoading,
|
||||
errorMessage,
|
||||
detail,
|
||||
screenshotPreviewUrl,
|
||||
manualDispatchForm,
|
||||
canManageTaskLifecycle,
|
||||
canCloseTasks,
|
||||
canViewSensitiveTaskData,
|
||||
claimUrl,
|
||||
claimLinkInvalid,
|
||||
screenshotSectionTitle,
|
||||
loadDetail,
|
||||
runAction,
|
||||
submitManualDispatch,
|
||||
copyText,
|
||||
openExternalLink,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
import {
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
|
||||
import { formatKuaishouRoleInfoLabel } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
import type { ChecklistItem, RoleInfoEntry, SummaryCard } from './types'
|
||||
|
||||
export function useAdminTaskKuaishouCloud(
|
||||
detail: Ref<AdminTaskDetail | null>,
|
||||
runAction: (
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const kuaishouCloudFlow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
|
||||
const kuaishouCloudRole = computed(() => kuaishouCloudFlow.value?.role || null)
|
||||
const isKuaishouCloudTask = computed(
|
||||
() => detail.value?.task.executorKey === 'kuaishou_ct_assisted',
|
||||
)
|
||||
|
||||
const summaryCards = computed<SummaryCard[]>(() => {
|
||||
if (!detail.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '任务状态',
|
||||
value: detail.value.task.status || '-',
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
label: '履约状态',
|
||||
value: detail.value.task.deliveryStatus || '-',
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
label: '当前角色',
|
||||
value: kuaishouCloudRole.value?.name || detail.value.task.roleName || '-',
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
label: '角色 ID',
|
||||
value: kuaishouCloudRole.value?.rid || detail.value.task.roleId || '-',
|
||||
tone: 'slate',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const kuaishouRoleInfoEntries = computed<RoleInfoEntry[]>(() => {
|
||||
const rawInfo = kuaishouCloudRole.value?.rawInfo
|
||||
if (!rawInfo) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(rawInfo)
|
||||
.filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== '')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: formatKuaishouRoleInfoLabel(key),
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
|
||||
}))
|
||||
})
|
||||
|
||||
const kuaishouCloudChecklist = computed<ChecklistItem[]>(() => {
|
||||
const flow = kuaishouCloudFlow.value
|
||||
const role = kuaishouCloudRole.value
|
||||
if (!flow) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'ticket',
|
||||
label: '核销码校验',
|
||||
status: flow.ticket.status || 'pending',
|
||||
detail: flow.ticket.verifiedAt
|
||||
? `已于 ${formatAdminDateTime(flow.ticket.verifiedAt)} 校验`
|
||||
: '等待客户提交并校验核销码',
|
||||
},
|
||||
{
|
||||
key: 'bind',
|
||||
label: '绑定资源',
|
||||
status: flow.binding.prepareStatus || 'pending',
|
||||
detail: flow.binding.bindPreparedAt
|
||||
? `绑定资源已准备,VN ${flow.binding.vnId || '-'}`
|
||||
: '等待准备 Cloud 绑定资源',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色识别',
|
||||
status: role?.status || 'pending',
|
||||
detail:
|
||||
role?.name || role?.rid
|
||||
? `${role?.name || '-'} / ${role?.rid || '-'}`
|
||||
: '客户绑定后刷新角色信息',
|
||||
},
|
||||
{
|
||||
key: 'dispatch',
|
||||
label: '发货执行',
|
||||
status: flow.dispatch.status || 'pending',
|
||||
detail: flow.dispatch.dispatchAt
|
||||
? `已于 ${formatAdminDateTime(flow.dispatch.dispatchAt)} 发货`
|
||||
: '等待客服确认绑定并发货',
|
||||
},
|
||||
{
|
||||
key: 'return',
|
||||
label: '退还号码',
|
||||
status: flow.returnNumber.status || 'pending',
|
||||
detail: flow.returnNumber.returnedAt
|
||||
? `已于 ${formatAdminDateTime(flow.returnNumber.returnedAt)} 退号`
|
||||
: '发货后执行退还号码',
|
||||
},
|
||||
{
|
||||
key: 'consume',
|
||||
label: '快手核销',
|
||||
status: flow.consume.status || 'pending',
|
||||
detail: flow.consume.consumedAt
|
||||
? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 完成核销`
|
||||
: flow.consume.errorMessage || '等待退号后核销收口',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
async function submitKuaishouCloudPrepare() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => prepareAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
|
||||
'绑定资源已准备完成',
|
||||
`确认开始为任务 ${detail.value.task.taskNo} 准备绑定资源吗?系统会自动检查背包、余额并申请虚拟号。`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submitKuaishouCloudDispatch() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
|
||||
'已完成绑定确认并发货',
|
||||
`确认客户已经完成绑定,并立即为任务 ${detail.value.task.taskNo} 执行发货吗?这个动作会把"确认绑定完成"和"发货"合并为一步。`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submitKuaishouCloudRefreshRoleInfo() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => refreshAdminTaskKuaishouCloudRoleInfo(detail.value!.task.taskId),
|
||||
'角色信息已刷新',
|
||||
`确认刷新任务 ${detail.value.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 cloudtentacles 的绑定结果。`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submitKuaishouCloudReturnNumber() {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await runAction(
|
||||
() => returnNumberAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId),
|
||||
'号码已退还',
|
||||
`确认退还任务 ${detail.value.task.taskNo} 当前使用的虚拟号吗?退号后该流程会正式收口。`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
kuaishouCloudFlow,
|
||||
kuaishouCloudRole,
|
||||
isKuaishouCloudTask,
|
||||
summaryCards,
|
||||
kuaishouRoleInfoEntries,
|
||||
kuaishouCloudChecklist,
|
||||
submitKuaishouCloudPrepare,
|
||||
submitKuaishouCloudDispatch,
|
||||
submitKuaishouCloudRefreshRoleInfo,
|
||||
submitKuaishouCloudReturnNumber,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user