Files
order_site/apps/frontend/src/views/admin/AdminWebhookEventsView.vue
T
2026-05-14 20:25:58 +08:00

466 lines
18 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 { computed, onMounted, ref } from 'vue'
import AdminPageHeader from '@/components/admin/AdminPageHeader.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,
})
const canReplay = hasAdminRole('admin')
const summary = computed(() => ({
processedCount: items.value.filter((item) => item.processed).length,
failedCount: items.value.filter((item) => !item.processed || Boolean(item.processError)).length,
importantCount: items.value.filter((item) => item.visibilityLevel === 'important').length,
}))
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 resetFilters() {
provider.value = ''
platform.value = ''
platformOrderId.value = ''
processed.value = ''
visibility.value = 'important'
relatedOrderId.value = ''
dateFrom.value = ''
dateTo.value = ''
void loadEvents(1)
}
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}` : '-'
}
function formatVisibilityLabel(value: string) {
return value === 'ignored' ? '已忽略' : '重要'
}
onMounted(loadEvents)
</script>
<template>
<div class="webhooks-page">
<AdminPageHeader title="Webhook 日志" description="查看平台推送处理情况、验签结果、请求来源和错误信息。">
<template #extra>
<span class="total-badge"> {{ pagination.total }} 条事件</span>
</template>
</AdminPageHeader>
<!-- 筛选 -->
<el-card shadow="never" class="filter-card">
<div class="filter-grid">
<el-input v-model="provider" placeholder="服务商,如 agiso" clearable @keyup.enter="loadEvents(1)" />
<el-input v-model="platform" placeholder="业务平台,如 xianyu / taobao" clearable @keyup.enter="loadEvents(1)" />
<el-select v-model="processed" placeholder="处理结果" clearable>
<el-option v-for="opt in adminWebhookProcessedOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-model="visibility" placeholder="可见级别" clearable>
<el-option v-for="opt in adminWebhookVisibilityOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-input v-model="platformOrderId" placeholder="平台订单号" clearable @keyup.enter="loadEvents(1)" />
<el-input v-model="relatedOrderId" placeholder="后台订单 ID" clearable @keyup.enter="loadEvents(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>
<div class="filter-actions">
<span class="filter-hint">Webhook 事件已拆成来源摘要订单验签请求来源几个信息块排查会更直观</span>
<div class="filter-buttons">
<el-button round @click="resetFilters">重置</el-button>
<el-button round type="primary" @click="loadEvents(1)">查询</el-button>
</div>
</div>
</el-card>
<!-- 摘要 -->
<div v-if="!loading && items.length > 0" class="summary-grid">
<el-card shadow="never" class="summary-card summary-card--success">
<span class="summary-label">当前页处理成功</span>
<strong class="summary-value">{{ summary.processedCount }}</strong>
<span class="summary-meta">已通过处理链路通常无需人工介入</span>
</el-card>
<el-card shadow="never" class="summary-card summary-card--danger">
<span class="summary-label">当前页异常 / 失败</span>
<strong class="summary-value">{{ summary.failedCount }}</strong>
<span class="summary-meta">优先查看验签处理错误和请求来源</span>
</el-card>
<el-card shadow="never" class="summary-card summary-card--primary">
<span class="summary-label">当前页重要事件</span>
<strong class="summary-value">{{ summary.importantCount }}</strong>
<span class="summary-meta">仅重要模式下更适合日常排查</span>
</el-card>
</div>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" style="margin-top: 16px" />
<!-- 列表 -->
<el-card shadow="never" class="table-card" v-loading="loading" element-loading-text="Webhook 列表加载中">
<template v-if="items.length === 0 && !loading">
<el-empty description="当前筛选条件下暂无 Webhook 事件" :image-size="60" />
</template>
<template v-else>
<div class="events-toolbar">
<strong>Webhook 列表</strong>
<span> {{ pagination.page }} 当前展示 {{ items.length }} </span>
</div>
<div class="event-list">
<article v-for="item in items" :key="item.eventId" class="event-card">
<div class="event-card-top">
<div class="event-identity">
<RouterLink :to="`/admin/webhook-events/${item.eventId}`" class="event-no">
#{{ item.eventId }} · {{ formatAdminWebhookEventType(item.eventType) }}
</RouterLink>
<div class="event-meta-row">
<span class="compact-chip">{{ item.provider || '-' }}</span>
<span class="compact-chip">{{ item.platform || '-' }}</span>
<span v-if="item.shopName || item.shopId" class="compact-chip compact-chip--muted">{{ item.shopName || item.shopId }}</span>
</div>
</div>
<div class="event-top-actions">
<span class="compact-chip compact-chip--muted">{{ formatVisibilityLabel(item.visibilityLevel) }}</span>
<el-button v-if="canReplay" :loading="actionLoadingId === item.eventId" size="small" @click="submitReplay(item)">重放</el-button>
</div>
</div>
<div class="event-grid">
<section class="event-section">
<span class="section-label">来源</span>
<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>
</section>
<section class="event-section">
<span class="section-label">事件摘要</span>
<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 event-key" :title="item.eventKey">Key: {{ item.eventKey }}</div>
</div>
</section>
<section class="event-section">
<span class="section-label">订单与店铺</span>
<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>
</section>
<section class="event-section">
<span class="section-label">验签与处理</span>
<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" :title="item.requestSign || '-'">签名 {{ item.requestSign || '-' }}</div>
<div v-if="item.processError" class="error-inline">{{ item.processError }}</div>
</div>
</section>
<section class="event-section">
<span class="section-label">请求来源</span>
<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>
</section>
<section class="event-section">
<span class="section-label">时间</span>
<div class="cell-stack">
<div class="cell-title">{{ formatAdminDateTime(item.createdAt) }}</div>
<div class="cell-subtle">事件 ID #{{ item.eventId }}</div>
</div>
</section>
</div>
</article>
</div>
<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"
style="margin-top: 16px; justify-content: center"
@current-change="loadEvents"
/>
</template>
</el-card>
</div>
</template>
<style scoped>
.webhooks-page { display: grid; gap: 0; }
.total-badge {
min-height: 34px;
padding: 0 14px;
border-radius: var(--radius-full);
background: var(--bg-surface);
border: 1px solid var(--border-default);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 700;
display: inline-flex;
align-items: center;
}
.filter-card { margin-top: var(--space-4); }
.filter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
}
.filter-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-3);
}
.filter-hint { color: var(--text-secondary); font-size: var(--text-xs); }
.filter-buttons { display: flex; gap: 10px; flex-shrink: 0; }
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin-top: var(--space-4);
}
.summary-card { position: relative; overflow: hidden; }
.summary-card::after {
content: '';
position: absolute;
inset: auto -26px -26px auto;
width: 96px;
height: 96px;
border-radius: var(--radius-full);
opacity: 0.14;
}
.summary-card--success::after { background: var(--color-success); }
.summary-card--danger::after { background: var(--color-danger); }
.summary-card--primary::after { background: var(--color-primary); }
.summary-label { color: var(--text-secondary); font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.04em; }
.summary-value { font-size: var(--text-3xl); line-height: 1; color: var(--text-primary); }
.summary-meta { color: var(--text-muted); font-size: var(--text-xs); }
.table-card { margin-top: var(--space-4); overflow: visible; }
.events-toolbar { margin-bottom: var(--space-3); }
.events-toolbar strong { display: block; color: var(--text-primary); font-size: var(--text-base); }
.events-toolbar span { display: block; margin-top: 4px; color: var(--text-secondary); font-size: var(--text-xs); }
.event-list { display: grid; gap: var(--space-3); }
.event-card {
display: grid;
gap: 14px;
padding: 14px;
border-radius: var(--radius-lg);
background: linear-gradient(180deg, rgba(248, 251, 255, 0.98) 0%, #ffffff 100%);
border: 1px solid var(--border-default);
}
.event-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
.event-identity { min-width: 0; display: grid; gap: var(--space-2); }
.event-no {
display: inline-block;
max-width: 100%;
font-size: var(--text-xl);
line-height: 1.1;
font-weight: 800;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
}
.event-no:hover { color: var(--color-primary-dark); }
.event-meta-row { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.event-top-actions { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; justify-content: flex-end; }
.compact-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
max-width: 100%;
padding: 0 10px;
border-radius: var(--radius-full);
background: #f4f7fb;
border: 1px solid var(--border-default);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 600;
}
.compact-chip--muted { color: var(--text-muted); }
.event-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
min-width: 0;
}
.event-section {
display: grid;
gap: 6px;
min-width: 0;
padding: 10px 11px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.78);
border: 1px solid var(--border-default);
}
.section-label { color: var(--text-muted); font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.cell-stack { display: grid; gap: 6px; min-width: 0; }
.cell-title { color: var(--text-primary); font-weight: 700; line-height: 1.4; word-break: break-word; }
.cell-subtle { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; word-break: break-word; }
.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: var(--radius-full);
color: #475467;
background: #f8fafc;
border: 1px solid rgba(148, 163, 184, 0.24);
font-size: var(--text-xs);
}
.inline-link { color: var(--color-primary-dark); font-weight: 700; text-decoration: none; }
.mono-inline { color: #344054; font-size: var(--text-xs); line-height: 1.6; font-family: var(--font-mono); overflow-wrap: anywhere; }
.event-key { color: var(--text-muted); font-size: 10px; line-height: 1.4; }
.error-inline { display: block; color: var(--color-danger); font-size: var(--text-xs); line-height: 1.5; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
@media (max-width: 1200px) {
.filter-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.event-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.event-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filter-actions { flex-direction: column; align-items: flex-start; }
}
@media (max-width: 640px) {
.filter-grid,
.summary-grid,
.event-grid { grid-template-columns: 1fr; }
.filter-buttons { width: 100%; }
.filter-buttons :deep(.el-button) { flex: 1 1 auto; }
.event-card-top { flex-direction: column; align-items: flex-start; }
.event-no { font-size: 18px; }
}
</style>