Files
order_site/apps/frontend/src/views/admin/AdminWebhookEventsView.vue
T

378 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
import { fetchAdminWebhookEvents, replayAdminWebhookEvent } from '@/services/admin'
import type { AdminPagination, AdminWebhookEventListItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatAdminWebhookEventType } from '@/utils/admin-webhook'
import { adminWebhookProcessedOptions, adminWebhookVisibilityOptions } from '@/utils/admin-options'
const loading = ref(true)
const actionLoadingId = ref<number | null>(null)
const errorMessage = ref('')
const items = ref<AdminWebhookEventListItem[]>([])
const provider = ref('')
const platform = ref('')
const platformOrderId = ref('')
const processed = ref('')
const visibility = ref('important')
const relatedOrderId = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const pagination = ref<AdminPagination>({
page: 1,
pageSize: 20,
total: 0,
})
async function loadEvents(page = pagination.value.page) {
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminWebhookEvents({
page,
pageSize: pagination.value.pageSize,
provider: provider.value.trim(),
platform: platform.value.trim(),
platformOrderId: platformOrderId.value.trim(),
processed: processed.value.trim(),
visibility: visibility.value.trim(),
relatedOrderId: relatedOrderId.value.trim(),
dateFrom: dateFrom.value.trim(),
dateTo: dateTo.value.trim(),
})
items.value = response.data.items
pagination.value = response.data.pagination
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取 webhook 列表失败'
} finally {
loading.value = false
}
}
async function submitReplay(item: AdminWebhookEventListItem) {
try {
await showConfirm(
`确认重放 webhook #${item.eventId} 吗?`,
'确认重放',
{
type: 'warning',
confirmButtonText: '继续重放',
cancelButtonText: '取消',
},
)
} catch {
return
}
actionLoadingId.value = item.eventId
try {
await replayAdminWebhookEvent(item.eventId)
showSuccess('Webhook 已重放')
await loadEvents()
} catch (error) {
showError(error instanceof Error ? error.message : '重放 webhook 失败')
} finally {
actionLoadingId.value = null
}
}
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(item: AdminWebhookEventListItem) {
return item.totalAmount ? `${item.totalAmount} ${item.currency}` : '-'
}
onMounted(loadEvents)
</script>
<template>
<section class="admin-panel">
<header class="panel-header">
<div>
<h1>Webhook 日志</h1>
<p>查看平台推送处理情况和错误信息。</p>
</div>
</header>
<section class="filter-bar">
<input v-model="provider" class="text-input" placeholder="服务商,如 agiso" />
<input v-model="platform" class="text-input" placeholder="业务平台,如 xianyu / taobao" />
<select v-model="processed" class="text-input select-input">
<option v-for="option in adminWebhookProcessedOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<select v-model="visibility" class="text-input select-input">
<option v-for="option in adminWebhookVisibilityOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<input v-model="platformOrderId" class="text-input" placeholder="平台订单号" />
</section>
<section class="filter-bar">
<input v-model="relatedOrderId" class="text-input" placeholder="关联订单 ID" />
<input v-model="dateFrom" class="text-input" type="date" placeholder="开始日期" />
<input v-model="dateTo" class="text-input" type="date" placeholder="结束日期" />
<el-button round type="primary" @click="() => loadEvents()">查询</el-button>
</section>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
<div v-if="loading" class="empty-block">Webhook 列表加载中</div>
<div v-else class="table-card">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>来源</th>
<th>事件摘要</th>
<th>订单与店铺</th>
<th>验签与处理</th>
<th>请求来源</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.eventId">
<td>
<RouterLink :to="`/admin/webhook-events/${item.eventId}`">{{ item.eventId }}</RouterLink>
</td>
<td>
<div class="cell-stack">
<div class="cell-title">{{ item.provider }} / {{ item.platform }}</div>
<div v-if="item.platformRaw && item.platformRaw !== item.platform" class="cell-subtle">
原始平台:{{ item.platformRaw }}
</div>
<div class="meta-chip-row">
<span class="meta-chip">店铺 {{ item.shopName || item.shopId || '-' }}</span>
<span v-if="item.shopId" class="meta-chip">ID {{ item.shopId }}</span>
</div>
</div>
</td>
<td>
<div class="cell-stack">
<div class="cell-title">{{ formatAdminWebhookEventType(item.eventType) }}</div>
<div v-if="item.messageText" class="cell-subtle">{{ item.messageText }}</div>
<div class="meta-chip-row">
<span v-if="item.aopic" class="meta-chip">aopic {{ item.aopic }}</span>
<span v-if="item.itemCount" class="meta-chip">商品 {{ item.itemCount }}</span>
</div>
<div class="mono-inline">Key: {{ item.eventKey }}</div>
</div>
</td>
<td>
<div class="cell-stack">
<div class="cell-title">平台单:{{ item.platformOrderId || '-' }}</div>
<div class="cell-subtle">买家:{{ item.buyerName || item.buyerId || '-' }}</div>
<div class="cell-subtle">金额:{{ formatAmount(item) }}</div>
<div class="meta-chip-row">
<span class="meta-chip">
关联订单
<template v-if="item.relatedOrderId">
<RouterLink class="inline-link" :to="`/admin/orders/${item.relatedOrderId}`">{{ item.relatedOrderId }}</RouterLink>
</template>
<template v-else>-</template>
</span>
<span class="meta-chip">任务 {{ item.taskCount }}</span>
</div>
</div>
</td>
<td>
<div class="cell-stack">
<div class="tag-row">
<AdminStatusTag :status="item.signatureValid ? 'success' : 'failed'" />
<AdminStatusTag :status="item.processed ? 'success' : 'failed'" />
</div>
<div class="cell-subtle">签名:{{ item.requestSign || '-' }}</div>
<div v-if="item.processError" class="error-inline">{{ item.processError }}</div>
</div>
</td>
<td>
<div class="cell-stack">
<div class="cell-title">{{ item.sourceHost || '-' }}</div>
<div class="cell-subtle">IP:{{ item.sourceIp || '-' }}</div>
<div class="cell-subtle line-clamp-2">UA:{{ item.userAgent || '-' }}</div>
<div class="cell-subtle">请求时间:{{ formatRequestTimestamp(item.requestTimestamp) }}</div>
</div>
</td>
<td>
<div class="cell-stack">
<div class="cell-title">{{ formatAdminDateTime(item.createdAt) }}</div>
<div class="cell-subtle">事件 ID #{{ item.eventId }}</div>
</div>
</td>
<td>
<el-button
v-if="hasAdminRole('admin')"
:loading="actionLoadingId === item.eventId"
link
type="primary"
@click="submitReplay(item)"
>
重放
</el-button>
</td>
</tr>
</tbody>
</table>
<AdminPaginationBar
:page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:loading="loading"
@change="loadEvents"
/>
</div>
</section>
</template>
<style scoped>
.admin-panel {
display: grid;
gap: 16px;
}
.panel-header h1 {
margin: 0;
color: #1d3555;
}
.panel-header p {
margin: 8px 0 0;
color: #64748b;
}
.filter-bar {
display: flex;
gap: 12px;
align-items: center;
}
.cell-stack {
display: grid;
gap: 6px;
}
.cell-title {
color: #0f172a;
font-weight: 700;
line-height: 1.4;
}
.cell-subtle {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.meta-chip-row,
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
color: #475467;
background: #f8fafc;
border: 1px solid rgba(148, 163, 184, 0.24);
font-size: 12px;
}
.inline-link {
color: #175cd3;
font-weight: 700;
}
.table-card,
.empty-block,
.error-copy {
padding: 18px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
}
.error-copy {
color: #b42318;
}
.error-inline {
display: block;
color: #b42318;
font-size: 12px;
line-height: 1.5;
}
.text-input {
width: 100%;
min-height: 42px;
padding: 0 14px;
border-radius: 14px;
border: 1px solid rgba(86, 108, 138, 0.18);
background: rgba(255, 255, 255, 0.94);
}
.select-input {
appearance: none;
}
.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, 0.08);
color: #334155;
vertical-align: top;
}
.data-table th {
color: #64748b;
font-size: 13px;
}
.mono-inline {
color: #475467;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace;
word-break: break-all;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>