318 lines
10 KiB
Vue
318 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
|
|
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
|
|
import { fetchAdminWebhookEventDetail, replayAdminWebhookEvent } from '@/services/admin'
|
|
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
|
import type { AdminWebhookEventDetail, AdminWebhookReplayResponse } from '@/types/admin'
|
|
import { hasAdminRole } from '@/utils/admin-auth'
|
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
|
import { stringifyDisplayJson } from '@/utils/date-time'
|
|
import { formatAdminWebhookEventType } from '@/utils/admin-webhook'
|
|
|
|
const route = useRoute()
|
|
const loading = ref(true)
|
|
const replayLoading = ref(false)
|
|
const errorMessage = ref('')
|
|
const detail = ref<AdminWebhookEventDetail | null>(null)
|
|
const lastReplayResult = ref<AdminWebhookReplayResponse['result'] | null>(null)
|
|
|
|
async function loadDetail() {
|
|
loading.value = true
|
|
errorMessage.value = ''
|
|
|
|
try {
|
|
const response = await fetchAdminWebhookEventDetail(String(route.params.eventId || ''))
|
|
detail.value = response.data
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : '读取 webhook 详情失败'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function submitReplay() {
|
|
if (!detail.value) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await showConfirm(
|
|
`确认重放 webhook #${detail.value.eventId} 吗?这会重新推进订单与任务处理。`,
|
|
'确认重放',
|
|
{
|
|
type: 'warning',
|
|
confirmButtonText: '继续重放',
|
|
cancelButtonText: '取消',
|
|
},
|
|
)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
replayLoading.value = true
|
|
|
|
try {
|
|
const response = await replayAdminWebhookEvent(detail.value.eventId)
|
|
lastReplayResult.value = response.data.result
|
|
showSuccess(`Webhook 已重放,生成/更新任务 ${response.data.result.taskCount} 条`)
|
|
await loadDetail()
|
|
} catch (error) {
|
|
showError(error instanceof Error ? error.message : '重放 webhook 失败')
|
|
} finally {
|
|
replayLoading.value = false
|
|
}
|
|
}
|
|
|
|
function prettyJson(value: Record<string, unknown>) {
|
|
return stringifyDisplayJson(value, 2)
|
|
}
|
|
|
|
function formatRequestTimestamp(value: string) {
|
|
const normalized = String(value || '').trim()
|
|
|
|
if (!/^\d{10,13}$/.test(normalized)) {
|
|
return normalized || '-'
|
|
}
|
|
|
|
const epochMs = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000
|
|
return formatAdminDateTime(new Date(epochMs).toISOString())
|
|
}
|
|
|
|
function formatAmount(detail: AdminWebhookEventDetail) {
|
|
return detail.totalAmount ? `${detail.totalAmount} ${detail.currency}` : '-'
|
|
}
|
|
|
|
onMounted(loadDetail)
|
|
</script>
|
|
|
|
<template>
|
|
<section class="admin-panel">
|
|
<header class="panel-header">
|
|
<div>
|
|
<h1>Webhook 详情</h1>
|
|
<p>查看原始请求并手动重放处理流程。</p>
|
|
</div>
|
|
<el-button v-if="hasAdminRole('admin')" :loading="replayLoading" round type="primary" @click="submitReplay">重放处理</el-button>
|
|
</header>
|
|
|
|
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
|
<div v-if="loading" class="empty-block">Webhook 详情加载中</div>
|
|
|
|
<template v-else-if="detail">
|
|
<section class="info-card">
|
|
<div class="overview-top">
|
|
<div>
|
|
<h3>{{ formatAdminWebhookEventType(detail.eventType) }}</h3>
|
|
<p class="muted-copy">
|
|
#{{ detail.eventId }} · {{ detail.provider }} / {{ detail.platform }}
|
|
<template v-if="detail.platformRaw && detail.platformRaw !== detail.platform">
|
|
· 原始平台 {{ detail.platformRaw }}
|
|
</template>
|
|
</p>
|
|
</div>
|
|
<div class="tag-row">
|
|
<AdminStatusTag :status="detail.signatureValid ? 'success' : 'failed'" />
|
|
<AdminStatusTag :status="detail.processed ? 'success' : 'failed'" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="summary-grid">
|
|
<div class="summary-item">
|
|
<span class="summary-label">店铺</span>
|
|
<strong>{{ detail.shopName || detail.shopId || '-' }}</strong>
|
|
<span class="summary-note">ID: {{ detail.shopId || '-' }}</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">平台订单</span>
|
|
<strong>{{ detail.platformOrderId || '-' }}</strong>
|
|
<span class="summary-note">aopic: {{ detail.aopic || '-' }}</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">关联订单</span>
|
|
<strong v-if="detail.relatedOrderId">
|
|
<RouterLink class="inline-link" :to="`/admin/orders/${detail.relatedOrderId}`">{{ detail.relatedOrderId }}</RouterLink>
|
|
</strong>
|
|
<strong v-else>-</strong>
|
|
<span class="summary-note">任务数: {{ detail.taskCount }}</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">买家</span>
|
|
<strong>{{ detail.buyerName || detail.buyerId || '-' }}</strong>
|
|
<span class="summary-note">买家 ID: {{ detail.buyerId || '-' }}</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">金额</span>
|
|
<strong>{{ formatAmount(detail) }}</strong>
|
|
<span class="summary-note">商品数: {{ detail.itemCount }}</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">请求时间</span>
|
|
<strong>{{ formatRequestTimestamp(detail.requestTimestamp) }}</strong>
|
|
<span class="summary-note">入库时间: {{ formatAdminDateTime(detail.createdAt) }}</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">来源 Host</span>
|
|
<strong>{{ detail.sourceHost || '-' }}</strong>
|
|
<span class="summary-note">来源 IP: {{ detail.sourceIp || '-' }}</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">消息说明</span>
|
|
<strong>{{ detail.messageText || '-' }}</strong>
|
|
<span class="summary-note">签名: {{ detail.requestSign || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="event-key-block">
|
|
<span class="summary-label">事件 Key</span>
|
|
<code>{{ detail.eventKey }}</code>
|
|
</div>
|
|
|
|
<div v-if="detail.processError" class="inline-error">{{ detail.processError }}</div>
|
|
</section>
|
|
|
|
<section v-if="lastReplayResult" class="table-card">
|
|
<h3>最近一次重放结果</h3>
|
|
<p class="result-copy">
|
|
{{ lastReplayResult.provider }} / {{ lastReplayResult.platform }}
|
|
<template v-if="lastReplayResult.platformRaw && lastReplayResult.platformRaw !== lastReplayResult.platform">
|
|
· 原始平台 {{ lastReplayResult.platformRaw }}
|
|
</template>
|
|
· 平台订单 {{ lastReplayResult.platformOrderId }}
|
|
· 事件 {{ formatAdminWebhookEventType(lastReplayResult.eventType) }}
|
|
· 任务数 {{ lastReplayResult.taskCount }}
|
|
</p>
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>任务 ID</th>
|
|
<th>任务号</th>
|
|
<th>状态</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="task in lastReplayResult.tasks" :key="task.taskId">
|
|
<td>{{ task.taskId }}</td>
|
|
<td>{{ task.taskNo }}</td>
|
|
<td><AdminStatusTag :status="task.status" /></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<section class="table-card">
|
|
<h3>解析后的 Payload</h3>
|
|
<pre class="json-block">{{ prettyJson(detail.payload) }}</pre>
|
|
</section>
|
|
|
|
<section class="table-card">
|
|
<h3>Headers</h3>
|
|
<pre class="json-block">{{ prettyJson(detail.headers) }}</pre>
|
|
</section>
|
|
|
|
<section class="table-card">
|
|
<h3>Query</h3>
|
|
<pre class="json-block">{{ prettyJson(detail.query) }}</pre>
|
|
</section>
|
|
|
|
<section class="table-card">
|
|
<h3>Body</h3>
|
|
<pre class="json-block">{{ prettyJson(detail.body) }}</pre>
|
|
</section>
|
|
</template>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.admin-panel { display: grid; gap: 16px; }
|
|
.panel-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
|
.panel-header h1 { margin: 0; color: #1d3555; }
|
|
.panel-header p { margin: 8px 0 0; color: #64748b; }
|
|
.info-card,.table-card,.empty-block,.error-copy {
|
|
padding: 18px; border-radius: 20px; background: rgba(255,255,255,.94); border: 1px solid rgba(86,108,138,.1);
|
|
}
|
|
.overview-top { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
|
|
.overview-top h3 { margin: 0; color: #0f172a; }
|
|
.muted-copy { margin: 8px 0 0; color: #64748b; }
|
|
.tag-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
.summary-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin-top: 16px;
|
|
}
|
|
.summary-item {
|
|
display: grid;
|
|
gap: 6px;
|
|
padding: 14px;
|
|
border-radius: 16px;
|
|
background: #f8fafc;
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
}
|
|
.summary-label {
|
|
color: #64748b;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
.summary-item strong {
|
|
color: #0f172a;
|
|
word-break: break-all;
|
|
}
|
|
.summary-note {
|
|
color: #64748b;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
word-break: break-all;
|
|
}
|
|
.event-key-block {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-top: 16px;
|
|
padding: 14px;
|
|
border-radius: 16px;
|
|
background: #f8fafc;
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
}
|
|
.event-key-block code {
|
|
color: #334155;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
word-break: break-all;
|
|
white-space: pre-wrap;
|
|
}
|
|
.error-copy { color: #b42318; }
|
|
.inline-error {
|
|
margin-top: 16px;
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
color: #b42318;
|
|
background: #fef3f2;
|
|
border: 1px solid #fecdca;
|
|
line-height: 1.6;
|
|
}
|
|
.result-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); color: #334155; }
|
|
.inline-link { color: #175cd3; }
|
|
.json-block {
|
|
margin: 12px 0 0;
|
|
padding: 14px;
|
|
border-radius: 14px;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
overflow: auto;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
@media (max-width: 960px) {
|
|
.overview-top {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.summary-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|