彻底重构-4

This commit is contained in:
yml
2026-04-09 15:51:24 +08:00
parent 60054adcc4
commit 4c395103a9
28 changed files with 1917 additions and 404 deletions
+5 -1
View File
@@ -62,7 +62,11 @@ const router = createRouter({
},
{
path: 'cdks',
component: () => import('@/views/admin/AdminCdksView.vue'),
redirect: '/admin/inventory',
},
{
path: 'inventory',
component: () => import('@/views/admin/AdminInventoryView.vue'),
},
{
path: 'webhook-events',
+29 -13
View File
@@ -3,8 +3,8 @@ import type {
AdminAgisoObservedShopItem,
AdminAgisoShopConfigItem,
AdminAuditLogItem,
AdminInventoryItemListItem,
AdminTaskActionResponse,
AdminCdkListItem,
AdminDashboardSummary,
AdminLoginResponse,
AdminOrderDetail,
@@ -98,8 +98,12 @@ export function retryAdminTask(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/retry`, {})
}
export function releaseAdminTaskCdk(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/release-cdk`, {})
export function releaseAdminTaskInventory(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/release-inventory`, {})
}
export function releaseAdminTaskInventoryBinding(taskId: number | string, bindingId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/inventory-bindings/${bindingId}/release`, {})
}
export function regenerateAdminTaskClaimLink(taskId: number | string) {
@@ -117,24 +121,36 @@ export function markAdminTaskManualReview(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/mark-manual-review`, {})
}
export function fetchAdminCdks(params?: Record<string, unknown>) {
return apiGet<{ items: AdminCdkListItem[]; pagination: AdminPagination }>('/api/v1/admin/cdks', params)
export function completeAdminTaskManualDispatch(
taskId: number | string,
payload: {
outcome: 'delivered' | 'failed'
resultMessage?: string
deliveryReference?: string
deliveredCredential?: string
},
) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/complete-manual-dispatch`, payload)
}
export function importAdminCdks(payload: Record<string, unknown>) {
return apiPost<{ total: number; created: number; duplicated: number }>('/api/v1/admin/cdks/import', payload)
export function fetchAdminInventoryItems(params?: Record<string, unknown>) {
return apiGet<{ items: AdminInventoryItemListItem[]; pagination: AdminPagination }>('/api/v1/admin/inventory', params)
}
export function createAdminCdk(payload: Record<string, unknown>) {
return apiPost<{ cdk: AdminCdkListItem | null }>('/api/v1/admin/cdks', payload)
export function importAdminInventoryItems(payload: Record<string, unknown>) {
return apiPost<{ total: number; created: number; duplicated: number }>('/api/v1/admin/inventory/import', payload)
}
export function releaseAdminInventoryCdk(cdkId: number | string) {
return apiPost<{ cdk: AdminCdkListItem | null }>(`/api/v1/admin/cdks/${cdkId}/release`, {})
export function createAdminInventoryItem(payload: Record<string, unknown>) {
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>('/api/v1/admin/inventory', payload)
}
export function invalidateAdminCdk(cdkId: number | string, payload?: Record<string, unknown>) {
return apiPost<{ cdk: AdminCdkListItem | null }>(`/api/v1/admin/cdks/${cdkId}/invalidate`, payload || {})
export function releaseAdminInventoryItem(inventoryItemId: number | string) {
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(`/api/v1/admin/inventory/${inventoryItemId}/release`, {})
}
export function invalidateAdminInventoryItem(inventoryItemId: number | string, payload?: Record<string, unknown>) {
return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(`/api/v1/admin/inventory/${inventoryItemId}/invalidate`, payload || {})
}
export function fetchAdminWebhookEvents(params?: Record<string, unknown>) {
+86 -18
View File
@@ -78,6 +78,26 @@ export interface AdminDashboardSummary {
skuWithInventory: number
}
export interface AdminTaskBindingSummary {
totalBindingCount: number
reservedBindingCount: number
consumedBindingCount: number
releasedBindingCount: number
roleKeys: string[]
}
export interface AdminOrderBindingSummary {
totalTaskCount: number
systemBoundTaskCount: number
completedBindingTaskCount: number
totalBindingCount: number
reservedBindingCount: number
consumedBindingCount: number
releasedBindingCount: number
systemBindingStatus: string
userBindingStatus: string
}
export interface AdminOrderListItem {
orderId: number
provider: string
@@ -104,6 +124,10 @@ export interface AdminOrderListItem {
userBindingStatus: string
systemBoundTaskCount: number
completedBindingTaskCount: number
totalBindingCount: number
reservedBindingCount: number
consumedBindingCount: number
releasedBindingCount: number
}
export interface AdminTaskListItem {
@@ -113,6 +137,10 @@ export interface AdminTaskListItem {
skuCode: string
skuName: string
status: string
executorKey: string
deliveryStatus: string
resultCode: string
resultMessage: string
systemBindingStatus: string
userBindingStatus: string
loginType: string
@@ -123,20 +151,23 @@ export interface AdminTaskListItem {
roleConfirmedAt: string | null
redeemedAt: string | null
retryCount: number
bindingSummary: AdminTaskBindingSummary
lastError: string
createdAt: string
updatedAt: string
reservedCdkCodeMasked: string
inventoryDisplayMasked: string
inventoryCredentialType: string
claimToken: string
screenshotPath: string
}
export interface AdminTaskOperations {
canRetry: boolean
canReleaseCdk: boolean
canReleaseInventory: boolean
canRegenerateClaimLink: boolean
canClose: boolean
canMarkManualReview: boolean
canCompleteManualDispatch: boolean
}
export interface AdminTaskActionResponse {
@@ -144,9 +175,11 @@ export interface AdminTaskActionResponse {
taskId: number
taskNo: string
status: string
deliveryStatus: string
resultCode: string
resultMessage: string
inventoryItemId: number | null
claimTokenId: number | null
reservedCdkId?: number | null
primaryClaimTokenId: number | null
lastError: string
updatedAt: string
}
@@ -175,13 +208,7 @@ export interface AdminOrderDetail {
updatedAt: string
itemSummary: string
rawPayload: Record<string, unknown>
bindingSummary: {
totalTaskCount: number
systemBoundTaskCount: number
completedBindingTaskCount: number
systemBindingStatus: string
userBindingStatus: string
}
bindingSummary: AdminOrderBindingSummary
}
items: Array<{
orderItemId: number
@@ -205,6 +232,7 @@ export interface AdminOrderDetail {
redeemedAt: string | null
lastError: string
retryCount: number
bindingSummary: AdminTaskBindingSummary
createdAt: string
updatedAt: string
}>
@@ -238,28 +266,68 @@ export interface AdminTaskDetail {
quantity: number
}
claimToken: null | {
claimTokenId: number
primaryClaimTokenId: number
token: string
status: string
expiredAt: string
}
cdk: null | {
cdkId: number
inventory: null | {
inventoryItemId: number
skuCode: string
batchNo: string
cdkCode: string
credentialType: string
displayValue: string
status: string
}
inventoryBindings: Array<{
bindingId: number
inventoryItemId: number
roleKey: string
quantity: number
bindingStatus: string
inventoryStatus: string
skuCode: string
batchNo: string
credentialType: string
displayValue: string
invalidReason: string
consumedAt: string | null
releasedAt: string | null
createdAt: string
updatedAt: string
metadata: Record<string, unknown>
isPrimary: boolean
canRelease: boolean
}>
artifacts: Record<string, unknown>
screenshotUrl: string
manualDispatch: null | {
outcome: string
deliveryReference: string
deliveredCredential: string
resultMessage: string
completedAt: string | null
completedBy: null | {
userId: number
username: string
role: string
}
}
events: Array<{
eventId: number
eventType: string
payload: Record<string, unknown>
createdAt: string
}>
operations: AdminTaskOperations
}
export interface AdminCdkListItem {
cdkId: number
export interface AdminInventoryItemListItem {
inventoryItemId: number
skuCode: string
batchNo: string
cdkCode: string
credentialType: string
displayValue: string
status: string
reservedByTaskId: number | null
reservedByTaskNo: string
+16 -4
View File
@@ -21,12 +21,14 @@ export function formatAuditAction(action: string) {
admin_user_role_updated: '修改用户角色',
admin_user_status_updated: '修改用户状态',
admin_user_password_reset: '重置用户密码',
task_release_cdk: '释放任务 CDK',
task_release_inventory: '释放任务库存',
task_release_inventory_binding: '释放任务库存绑定',
task_regenerate_claim_link: '重发领取链接',
task_closed: '关闭任务',
task_mark_manual_review: '转人工处理',
inventory_cdk_released: '释放库存 CDK',
inventory_cdk_invalidated: '作废库存 CDK',
task_complete_manual_dispatch: '完成人工履约',
inventory_item_released: '释放库存项',
inventory_item_invalidated: '作废库存项',
webhook_replayed: '重放 Webhook',
platform_shop_config_updated: '更新店铺配置',
}
@@ -38,10 +40,20 @@ export function formatAuditTargetType(targetType: string) {
const labelMap: Record<string, string> = {
admin_user: '后台用户',
task: '交付任务',
cdk: 'CDK 库存',
task_inventory_binding: '任务库存绑定',
inventory_item: '库存项',
webhook_event: 'Webhook 事件',
platform_config: '平台配置',
}
return formatStatusWithRaw(targetType, labelMap)
}
export function formatTaskEventType(eventType: string) {
const labelMap: Record<string, string> = {
manual_dispatch_completed: '人工履约已回写',
inventory_binding_released: '库存绑定已释放',
}
return formatStatusWithRaw(eventType, labelMap)
}
+5 -5
View File
@@ -23,7 +23,7 @@ export const adminTaskStatusOptions = [
{ label: '已关闭', value: 'closed' },
]
export const adminCdkStatusOptions = [
export const adminInventoryStatusOptions = [
{ label: '全部', value: '' },
{ label: '可用', value: 'available' },
{ label: '已预占', value: 'reserved' },
@@ -55,12 +55,12 @@ export const adminAuditActionOptions = [
{ label: '修改用户角色', value: 'admin_user_role_updated' },
{ label: '修改用户状态', value: 'admin_user_status_updated' },
{ label: '重置用户密码', value: 'admin_user_password_reset' },
{ label: '释放任务 CDK', value: 'task_release_cdk' },
{ label: '释放任务库存', value: 'task_release_inventory' },
{ label: '重发领取链接', value: 'task_regenerate_claim_link' },
{ label: '关闭任务', value: 'task_closed' },
{ label: '转人工处理', value: 'task_mark_manual_review' },
{ label: '释放库存 CDK', value: 'inventory_cdk_released' },
{ label: '作废库存 CDK', value: 'inventory_cdk_invalidated' },
{ label: '释放库存', value: 'inventory_item_released' },
{ label: '作废库存', value: 'inventory_item_invalidated' },
{ label: '重放 Webhook', value: 'webhook_replayed' },
]
@@ -68,6 +68,6 @@ export const adminAuditTargetTypeOptions = [
{ label: '全部', value: '' },
{ label: '后台用户', value: 'admin_user' },
{ label: '交付任务', value: 'task' },
{ label: 'CDK 库存', value: 'cdk' },
{ label: '库存', value: 'inventory_item' },
{ label: 'Webhook 事件', value: 'webhook_event' },
]
@@ -4,10 +4,10 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import { createAdminCdk, fetchAdminCdks, importAdminCdks, invalidateAdminCdk, releaseAdminInventoryCdk } from '@/services/admin'
import type { AdminCdkListItem, AdminPagination } from '@/types/admin'
import { createAdminInventoryItem, fetchAdminInventoryItems, importAdminInventoryItems, invalidateAdminInventoryItem, releaseAdminInventoryItem } from '@/services/admin'
import type { AdminInventoryItemListItem, AdminPagination } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { adminCdkStatusOptions } from '@/utils/admin-options'
import { adminInventoryStatusOptions } from '@/utils/admin-options'
const loading = ref(true)
const importing = ref(false)
@@ -18,20 +18,20 @@ const skuCode = ref('')
const status = ref('')
const batchNo = ref('')
const bulkText = ref('')
const singleCdkCode = ref('')
const items = ref<AdminCdkListItem[]>([])
const singleDisplayValue = ref('')
const items = ref<AdminInventoryItemListItem[]>([])
const pagination = ref<AdminPagination>({
page: 1,
pageSize: 20,
total: 0,
})
async function loadCdks(page = pagination.value.page) {
async function loadInventoryItems(page = pagination.value.page) {
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminCdks({
const response = await fetchAdminInventoryItems({
page,
pageSize: pagination.value.pageSize,
skuCode: skuCode.value.trim(),
@@ -41,32 +41,32 @@ async function loadCdks(page = pagination.value.page) {
items.value = response.data.items
pagination.value = response.data.pagination
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取 CDK 列表失败'
errorMessage.value = error instanceof Error ? error.message : '读取库存列表失败'
} finally {
loading.value = false
}
}
async function submitCreate() {
if (!skuCode.value.trim() || !singleCdkCode.value.trim()) {
ElMessage.error('请填写 SKU 和单条 CDK')
if (!skuCode.value.trim() || !singleDisplayValue.value.trim()) {
ElMessage.error('请填写 SKU 和库存凭据')
return
}
creating.value = true
try {
await createAdminCdk({
await createAdminInventoryItem({
skuCode: skuCode.value.trim(),
batchNo: batchNo.value.trim(),
cdkCode: singleCdkCode.value.trim(),
displayValue: singleDisplayValue.value.trim(),
})
ElMessage.success('CDK 已新增')
singleCdkCode.value = ''
await loadCdks()
ElMessage.success('库存项已新增')
singleDisplayValue.value = ''
await loadInventoryItems()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '新增 CDK 失败')
ElMessage.error(error instanceof Error ? error.message : '新增库存项失败')
} finally {
creating.value = false
}
@@ -79,14 +79,14 @@ async function submitImport() {
.filter(Boolean)
if (!skuCode.value.trim() || codes.length === 0) {
ElMessage.error('请填写 SKU 并输入至少一条 CDK')
ElMessage.error('请填写 SKU 并输入至少一条库存凭据')
return
}
importing.value = true
try {
const response = await importAdminCdks({
const response = await importAdminInventoryItems({
skuCode: skuCode.value.trim(),
batchNo: batchNo.value.trim(),
codes,
@@ -94,7 +94,7 @@ async function submitImport() {
ElMessage.success(`导入完成,新增 ${response.data.created}`)
bulkText.value = ''
await loadCdks()
await loadInventoryItems()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败')
} finally {
@@ -102,7 +102,7 @@ async function submitImport() {
}
}
async function runRowAction(cdkId: number, action: () => Promise<unknown>, successMessage: string, confirmText: string) {
async function runRowAction(inventoryItemId: number, action: () => Promise<unknown>, successMessage: string, confirmText: string) {
try {
await ElMessageBox.confirm(confirmText, '确认操作', {
type: 'warning',
@@ -113,12 +113,12 @@ async function runRowAction(cdkId: number, action: () => Promise<unknown>, succe
return
}
actionLoadingId.value = cdkId
actionLoadingId.value = inventoryItemId
try {
await action()
ElMessage.success(successMessage)
await loadCdks()
await loadInventoryItems()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '操作失败')
} finally {
@@ -126,15 +126,15 @@ async function runRowAction(cdkId: number, action: () => Promise<unknown>, succe
}
}
onMounted(loadCdks)
onMounted(loadInventoryItems)
</script>
<template>
<section class="admin-panel">
<header class="panel-header">
<div>
<h1>CDK 管理</h1>
<p>查看库存快速新增 CDK并处理预占或失效库存</p>
<h1>库存管理</h1>
<p>查看库存快速新增凭据并处理预占或失效库存</p>
</div>
</header>
@@ -142,28 +142,28 @@ onMounted(loadCdks)
<div class="form-row">
<input v-model="skuCode" class="text-input" placeholder="SKU,例如 dnf-cdk-a" />
<select v-model="status" class="text-input select-input">
<option v-for="option in adminCdkStatusOptions" :key="option.value" :value="option.value">
<option v-for="option in adminInventoryStatusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<input v-model="batchNo" class="text-input" placeholder="批次号筛选/导入批次号" />
<el-button round @click="() => loadCdks()">查询列表</el-button>
<el-button round @click="() => loadInventoryItems()">查询列表</el-button>
</div>
<div class="form-row form-row-top">
<input v-model="singleCdkCode" class="text-input" placeholder="单条新增 CDK" />
<input v-model="singleDisplayValue" class="text-input" placeholder="单条新增库存凭据" />
<el-button :loading="creating" round type="success" @click="submitCreate">单条新增</el-button>
<el-button :loading="importing" round type="primary" @click="submitImport">批量导入</el-button>
</div>
<textarea
v-model="bulkText"
class="text-area"
placeholder="每行一个 CDK"
placeholder="每行一个库存凭据"
/>
</section>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
<div v-if="loading" class="empty-block">CDK 列表加载中</div>
<div v-if="loading" class="empty-block">库存列表加载中</div>
<div v-else class="table-card">
<table class="data-table">
@@ -176,13 +176,13 @@ onMounted(loadCdks)
<th>订单号</th>
<th>预占任务</th>
<th>失效原因</th>
<th>CDK</th>
<th>凭据内容</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.cdkId">
<td>{{ item.cdkId }}</td>
<tr v-for="item in items" :key="item.inventoryItemId">
<td>{{ item.inventoryItemId }}</td>
<td>{{ item.skuCode }}</td>
<td>{{ item.batchNo || '-' }}</td>
<td>
@@ -195,23 +195,23 @@ onMounted(loadCdks)
<td>{{ item.platformOrderId || '-' }}</td>
<td>{{ item.reservedByTaskNo || item.reservedByTaskId || '-' }}</td>
<td>{{ item.invalidReason || '-' }}</td>
<td>{{ item.cdkCode }}</td>
<td>{{ item.displayValue }}</td>
<td>
<div class="action-stack">
<el-button
v-if="hasAdminRole('admin') && item.status === 'reserved'"
:loading="actionLoadingId === item.cdkId"
:loading="actionLoadingId === item.inventoryItemId"
link
@click="runRowAction(item.cdkId, () => releaseAdminInventoryCdk(item.cdkId), 'CDK 已释放', `确认释放 CDK ${item.cdkCode} 吗?`)"
@click="runRowAction(item.inventoryItemId, () => releaseAdminInventoryItem(item.inventoryItemId), '库存项已释放', `确认释放库存凭据 ${item.displayValue} 吗?`)"
>
释放
</el-button>
<el-button
v-if="hasAdminRole('admin') && item.status === 'available'"
:loading="actionLoadingId === item.cdkId"
:loading="actionLoadingId === item.inventoryItemId"
link
type="danger"
@click="runRowAction(item.cdkId, () => invalidateAdminCdk(item.cdkId, { reason: '后台手动作废' }), 'CDK 已作废', `确认作废 CDK ${item.cdkCode} 吗?作废后不会再参与分配。`)"
@click="runRowAction(item.inventoryItemId, () => invalidateAdminInventoryItem(item.inventoryItemId, { reason: '后台手动作废' }), '库存项已作废', `确认作废库存凭据 ${item.displayValue} 吗?作废后不会再参与分配。`)"
>
作废
</el-button>
@@ -225,7 +225,7 @@ onMounted(loadCdks)
:page-size="pagination.pageSize"
:total="pagination.total"
:loading="loading"
@change="loadCdks"
@change="loadInventoryItems"
/>
</div>
</section>
@@ -14,7 +14,7 @@ const navItems = computed(() => {
{ to: '/admin/dashboard', label: '概览' },
{ to: '/admin/orders', label: '订单' },
{ to: '/admin/tasks', label: '任务' },
{ to: '/admin/cdks', label: 'CDK' },
{ to: '/admin/inventory', label: '库存' },
{ to: '/admin/webhook-events', label: 'Webhook' },
]
@@ -36,6 +36,15 @@ function formatSpecSummary(spec: Record<string, unknown>) {
return entries.length > 0 ? entries.join(' | ') : '-'
}
function formatBindingRoleSummary(roleKeys: string[]) {
return Array.isArray(roleKeys) && roleKeys.length > 0 ? roleKeys.join(' / ') : '-'
}
function formatTaskBindingCountSummary(task: AdminOrderDetail['tasks'][number]) {
const summary = task.bindingSummary
return `${summary.totalBindingCount} 条 · 预占 ${summary.reservedBindingCount} · 已消费 ${summary.consumedBindingCount} · 已释放 ${summary.releasedBindingCount}`
}
</script>
<template>
@@ -91,6 +100,11 @@ function formatSpecSummary(spec: Record<string, unknown>) {
<strong>{{ detail.order.bindingSummary.completedBindingTaskCount }}/{{ detail.order.bindingSummary.totalTaskCount }} 完整绑定</strong>
<small>{{ detail.order.bindingSummary.systemBoundTaskCount }}/{{ detail.order.bindingSummary.totalTaskCount }} 系统绑定</small>
</article>
<article class="info-tile">
<span>库存绑定</span>
<strong>{{ detail.order.bindingSummary.totalBindingCount }} 条绑定记录</strong>
<small>预占 {{ detail.order.bindingSummary.reservedBindingCount }} · 已消费 {{ detail.order.bindingSummary.consumedBindingCount }} · 已释放 {{ detail.order.bindingSummary.releasedBindingCount }}</small>
</article>
</div>
<table class="meta-table">
@@ -137,6 +151,7 @@ function formatSpecSummary(spec: Record<string, unknown>) {
<th>状态</th>
<th>系统绑定</th>
<th>完整绑定</th>
<th>库存绑定</th>
<th>登录方式</th>
<th>错误</th>
</tr>
@@ -149,6 +164,10 @@ function formatSpecSummary(spec: Record<string, unknown>) {
<td><AdminStatusTag :status="task.status" /></td>
<td><AdminStatusTag :status="task.systemBindingStatus" /></td>
<td><AdminStatusTag :status="task.userBindingStatus" /></td>
<td>
<div>{{ formatTaskBindingCountSummary(task) }}</div>
<div class="binding-copy">角色 {{ formatBindingRoleSummary(task.bindingSummary.roleKeys) }}</div>
</td>
<td>{{ task.loginType || '-' }}</td>
<td>{{ task.lastError || '-' }}</td>
</tr>
@@ -248,6 +267,11 @@ function formatSpecSummary(spec: Record<string, unknown>) {
.info-tile strong {
color: #1d3555;
}
.binding-copy {
margin-top: 6px;
color: #64748b;
font-size: 12px;
}
.meta-table,
.data-table { width: 100%; border-collapse: collapse; }
.meta-table th,
@@ -1,20 +1,22 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import {
closeAdminTask,
completeAdminTaskManualDispatch,
fetchAdminTaskScreenshot,
fetchAdminTaskDetail,
markAdminTaskManualReview,
regenerateAdminTaskClaimLink,
releaseAdminTaskCdk,
releaseAdminTaskInventoryBinding,
retryAdminTask,
} from '@/services/admin'
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatTaskEventType } from '@/utils/admin-display'
import { formatAdminDateTime } from '@/utils/admin-time'
const route = useRoute()
@@ -24,6 +26,11 @@ const errorMessage = ref('')
const detail = ref<AdminTaskDetail | null>(null)
const lastClaimUrl = ref('')
const screenshotPreviewUrl = ref('')
const manualDispatchForm = reactive({
deliveryReference: '',
deliveredCredential: '',
resultMessage: '',
})
async function loadDetail() {
loading.value = true
@@ -32,6 +39,7 @@ async function loadDetail() {
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 : '读取任务详情失败'
@@ -93,6 +101,49 @@ function clearScreenshotPreview() {
}
}
function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
manualDispatchForm.deliveryReference = taskDetail.manualDispatch?.deliveryReference || ''
manualDispatchForm.deliveredCredential = taskDetail.manualDispatch?.deliveredCredential || ''
manualDispatchForm.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.resultMessage,
deliveryReference: manualDispatchForm.deliveryReference,
deliveredCredential: manualDispatchForm.deliveredCredential,
}),
actionLabel,
confirmText,
)
}
function formatTaskEventPayload(payload: Record<string, unknown>) {
const parts = [
payload.outcome ? `结果 ${payload.outcome}` : '',
payload.resultCode ? `代码 ${payload.resultCode}` : '',
payload.resultMessage ? `说明 ${payload.resultMessage}` : '',
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
].filter(Boolean)
if (parts.length > 0) {
return parts.join(' · ')
}
return JSON.stringify(payload || {})
}
onMounted(loadDetail)
onBeforeUnmount(clearScreenshotPreview)
</script>
@@ -113,6 +164,7 @@ onBeforeUnmount(clearScreenshotPreview)
<section class="info-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>系统绑定<AdminStatusTag :status="detail.task.systemBindingStatus" /> · 完整绑定<AdminStatusTag :status="detail.task.userBindingStatus" /></p>
<p>角色{{ detail.task.roleName || '-' }} / {{ detail.task.roleId || '-' }}</p>
<p>最后错误{{ detail.task.lastError || '-' }}</p>
@@ -129,15 +181,6 @@ onBeforeUnmount(clearScreenshotPreview)
>
重试任务
</el-button>
<el-button
v-if="hasAdminRole('admin')"
:disabled="!detail.operations.canReleaseCdk"
:loading="actionLoading"
round
@click="runAction(() => releaseAdminTaskCdk(detail!.task.taskId), 'CDK 已释放', `确认释放任务 ${detail!.task.taskNo} 当前预占的 CDK 吗?`)"
>
释放 CDK
</el-button>
<el-button
v-if="hasAdminRole('admin')"
:disabled="!detail.operations.canRegenerateClaimLink"
@@ -156,6 +199,25 @@ onBeforeUnmount(clearScreenshotPreview)
>
转人工
</el-button>
<el-button
v-if="hasAdminRole('admin') && detail.operations.canCompleteManualDispatch"
:loading="actionLoading"
round
type="success"
@click="submitManualDispatch('delivered')"
>
人工完成
</el-button>
<el-button
v-if="hasAdminRole('admin') && detail.operations.canCompleteManualDispatch"
:loading="actionLoading"
round
type="danger"
plain
@click="submitManualDispatch('failed')"
>
履约失败
</el-button>
<el-button
v-if="hasAdminRole('admin')"
:disabled="!detail.operations.canClose"
@@ -174,8 +236,13 @@ onBeforeUnmount(clearScreenshotPreview)
<tbody>
<tr><th>商品</th><td>{{ detail.orderItem?.skuName || '-' }}</td></tr>
<tr><th>SKU</th><td>{{ detail.orderItem?.skuCode || '-' }}</td></tr>
<tr><th>预占 CDK</th><td>{{ detail.cdk?.cdkCode || '-' }}</td></tr>
<tr><th>CDK 状态</th><td><AdminStatusTag :status="detail.cdk?.status || ''" /></td></tr>
<tr><th>任务执行器</th><td>{{ detail.task.executorKey || '-' }}</td></tr>
<tr><th>履约状态</th><td><AdminStatusTag :status="detail.task.deliveryStatus || ''" /></td></tr>
<tr><th>结果代码</th><td>{{ detail.task.resultCode || '-' }}</td></tr>
<tr><th>结果说明</th><td>{{ detail.task.resultMessage || '-' }}</td></tr>
<tr><th>库存凭据</th><td>{{ detail.inventory?.displayValue || '-' }}</td></tr>
<tr><th>凭据类型</th><td>{{ detail.inventory?.credentialType || '-' }}</td></tr>
<tr><th>库存状态</th><td><AdminStatusTag :status="detail.inventory?.status || ''" /></td></tr>
<tr><th>Claim Token</th><td>{{ detail.claimToken?.token || '-' }}</td></tr>
<tr><th>Token 状态</th><td><AdminStatusTag :status="detail.claimToken?.status || ''" /></td></tr>
<tr><th>Token 过期</th><td>{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}</td></tr>
@@ -188,6 +255,129 @@ onBeforeUnmount(clearScreenshotPreview)
</table>
</section>
<section class="table-card">
<h3>库存绑定</h3>
<p class="section-copy">这里展示任务当前和历史绑定过的库存项便于核对多库存任务的真实履约轨迹</p>
<table class="data-table">
<thead>
<tr>
<th>绑定</th>
<th>角色</th>
<th>库存项</th>
<th>状态</th>
<th>凭据内容</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="detail.inventoryBindings.length === 0">
<td colspan="7" class="table-empty">当前任务还没有库存绑定记录</td>
</tr>
<tr v-for="binding in detail.inventoryBindings" :key="binding.bindingId">
<td>
<div>#{{ binding.bindingId }}</div>
<div v-if="binding.isPrimary" class="binding-primary">主库存项</div>
</td>
<td>
<div>{{ binding.roleKey || '-' }}</div>
<div class="binding-meta">数量 {{ binding.quantity }}</div>
</td>
<td>
<div>ID {{ binding.inventoryItemId }}</div>
<div>{{ binding.skuCode || '-' }}</div>
<div class="binding-meta">{{ binding.credentialType || '-' }} · {{ binding.batchNo || '无批次' }}</div>
</td>
<td>
<div class="status-stack">
<AdminStatusTag :status="binding.bindingStatus" />
<AdminStatusTag :status="binding.inventoryStatus" />
</div>
<div v-if="binding.invalidReason" class="binding-meta">作废原因{{ binding.invalidReason }}</div>
</td>
<td>{{ binding.displayValue || '-' }}</td>
<td>
<div>绑定 {{ formatAdminDateTime(binding.createdAt) }}</div>
<div class="binding-meta">消费 {{ formatAdminDateTime(binding.consumedAt) }}</div>
<div class="binding-meta">释放 {{ formatAdminDateTime(binding.releasedAt) }}</div>
</td>
<td>
<el-button
v-if="hasAdminRole('admin') && binding.canRelease"
:loading="actionLoading"
link
@click="runAction(
() => releaseAdminTaskInventoryBinding(detail!.task.taskId, binding.bindingId),
'库存绑定已释放',
`确认释放绑定 #${binding.bindingId} 对应的库存项 ${binding.displayValue || binding.inventoryItemId} 吗?`
)"
>
释放绑定
</el-button>
<span v-else class="binding-meta">-</span>
</td>
</tr>
</tbody>
</table>
</section>
<section v-if="detail.task.executorKey === 'manual_dispatch'" class="table-card">
<h3>人工履约回写</h3>
<div class="manual-grid">
<label class="field-block">
<span>外部流水 / 单号</span>
<el-input
v-model="manualDispatchForm.deliveryReference"
maxlength="120"
placeholder="例如快递单号、平台消息回执号"
/>
</label>
<label class="field-block">
<span>已交付内容</span>
<el-input
v-model="manualDispatchForm.deliveredCredential"
maxlength="500"
placeholder="可填写人工发送的卡密、链接或关键信息"
type="textarea"
:rows="3"
/>
</label>
<label class="field-block">
<span>处理备注</span>
<el-input
v-model="manualDispatchForm.resultMessage"
maxlength="500"
placeholder="说明实际履约结果,失败时建议写清原因"
type="textarea"
:rows="4"
/>
</label>
</div>
<p class="manual-hint">回写后会直接更新任务终态不再走旧式领取链接流程</p>
<div v-if="detail.manualDispatch" class="manual-summary">
<p>最近回写<AdminStatusTag :status="detail.manualDispatch.outcome || detail.task.deliveryStatus" /> · {{ formatAdminDateTime(detail.manualDispatch.completedAt) }}</p>
<p>处理人{{ detail.manualDispatch.completedBy?.username || '-' }} / {{ detail.manualDispatch.completedBy?.role || '-' }}</p>
<p>流水号{{ detail.manualDispatch.deliveryReference || '-' }}</p>
<p>交付内容{{ detail.manualDispatch.deliveredCredential || '-' }}</p>
<p>备注{{ detail.manualDispatch.resultMessage || '-' }}</p>
</div>
</section>
<section v-if="detail.events.length > 0" class="table-card">
<h3>任务事件</h3>
<table class="data-table">
<tbody>
<tr v-for="event in detail.events" :key="event.eventId">
<th>{{ formatTaskEventType(event.eventType) }}</th>
<td>
<div class="event-copy">{{ formatTaskEventPayload(event.payload) }}</div>
<div class="event-time">{{ formatAdminDateTime(event.createdAt) }}</div>
</td>
</tr>
</tbody>
</table>
</section>
<section v-if="detail.screenshotUrl" class="table-card">
<h3>结果截图</h3>
<p v-if="!screenshotPreviewUrl" class="screenshot-empty">截图加载中或当前不可读取</p>
@@ -208,9 +398,22 @@ onBeforeUnmount(clearScreenshotPreview)
.error-copy { color: #b42318; }
.action-row { display: flex; flex-wrap: wrap; gap: 12px; }
.table-card h3 { margin: 0 0 12px; color: #1d3555; }
.section-copy { margin: 0 0 12px; color: #64748b; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th,.data-table td { padding: 12px 10px; text-align: left; border-bottom: 1px solid rgba(86,108,138,.08); }
.data-table th { width: 160px; color: #64748b; }
.status-stack { display: flex; flex-wrap: wrap; gap: 8px; }
.table-empty { text-align: center; color: #64748b; }
.binding-primary { margin-top: 4px; color: #0f766e; font-size: 12px; font-weight: 600; }
.binding-meta { margin-top: 4px; color: #64748b; font-size: 12px; }
.manual-grid { display: grid; gap: 14px; }
.field-block { display: grid; gap: 8px; color: #1d3555; font-weight: 600; }
.manual-hint { margin: 14px 0 0; color: #64748b; }
.manual-summary { margin-top: 16px; padding: 14px; border-radius: 16px; background: rgba(248,250,252,.9); }
.manual-summary p { margin: 0 0 8px; color: #334155; }
.manual-summary p:last-child { margin-bottom: 0; }
.event-copy { color: #334155; }
.event-time { margin-top: 6px; color: #64748b; font-size: 12px; }
.screenshot-link { display: inline-block; margin-bottom: 12px; color: #175cd3; text-decoration: none; }
.screenshot-empty { margin: 0 0 12px; color: #64748b; }
.screenshot-preview {
@@ -9,7 +9,6 @@ import {
fetchAdminTasks,
markAdminTaskManualReview,
regenerateAdminTaskClaimLink,
releaseAdminTaskCdk,
retryAdminTask,
} from '@/services/admin'
import type { AdminPagination, AdminTaskActionResponse, AdminTaskListItem } from '@/types/admin'
@@ -34,7 +33,7 @@ const pagination = ref<AdminPagination>({
total: 0,
})
type AdminTaskActionKey = 'retry' | 'regenerate_claim_link' | 'release_cdk' | 'manual_review' | 'close'
type AdminTaskActionKey = 'retry' | 'regenerate_claim_link' | 'manual_review' | 'close'
async function loadTasks(page = pagination.value.page) {
loading.value = true
@@ -60,6 +59,21 @@ async function loadTasks(page = pagination.value.page) {
}
}
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}`
}
function closeActionMenu() {
openedActionTaskId.value = null
}
@@ -139,16 +153,6 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
return
}
if (actionKey === 'release_cdk') {
void runAction(
item.taskId,
() => releaseAdminTaskCdk(item.taskId),
'CDK 已释放',
`确认释放任务 ${item.taskNo} 当前预占的 CDK 吗?`,
)
return
}
if (actionKey === 'manual_review') {
void runAction(
item.taskId,
@@ -173,7 +177,7 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
<header class="panel-header">
<div>
<h1>交付任务</h1>
<p>查看交付状态角色识别预占 CDK 和错误信息</p>
<p>查看交付状态角色识别库存凭据分配和错误信息</p>
</div>
</header>
@@ -212,7 +216,7 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
<tr>
<th>任务信息</th>
<th>任务进度</th>
<th>商品 / CDK</th>
<th>商品 / 凭据</th>
<th>角色绑定</th>
<th>错误</th>
<th>操作</th>
@@ -249,7 +253,15 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
<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.reservedCdkCodeMasked || '-'">CDK {{ item.reservedCdkCodeMasked || '-' }}</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>
</td>
<td>
@@ -265,6 +277,7 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
<td>
<div class="action-cell">
<el-button
:disabled="item.executorKey === 'manual_dispatch' || !['retry_pending', 'manual_review', 'waiting_inventory'].includes(item.status)"
:loading="actionLoadingId === item.taskId"
class="primary-action"
plain
@@ -283,12 +296,14 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
<span class="dropdown-icon">{{ openedActionTaskId === item.taskId ? '▴' : '▾' }}</span>
</el-button>
<div v-if="openedActionTaskId === item.taskId" class="more-action-menu">
<button class="menu-action-button" type="button" @click="handleActionCommand(`regenerate_claim_link:${item.taskId}`)">
<button
v-if="item.executorKey !== 'manual_dispatch'"
class="menu-action-button"
type="button"
@click="handleActionCommand(`regenerate_claim_link:${item.taskId}`)"
>
重发链接
</button>
<button class="menu-action-button" type="button" @click="handleActionCommand(`release_cdk:${item.taskId}`)">
释放 CDK
</button>
<button class="menu-action-button" type="button" @click="handleActionCommand(`manual_review:${item.taskId}`)">
转人工
</button>
@@ -450,6 +465,11 @@ function handleActionCommand(command: `${AdminTaskActionKey}:${number}`) {
color: #98a2b3;
}
.goods-subline-strong {
color: #1d3555;
font-weight: 600;
}
.cell-error {
display: -webkit-box;
overflow: hidden;