416 lines
13 KiB
Vue
416 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
|
||
import { useAdminListPage } from "@/composables/useAdminListPage";
|
||
import { useAdminListQuerySync } from "@/composables/useAdminListQuerySync";
|
||
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 { 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 route = useRoute();
|
||
const router = useRouter();
|
||
const { readQueryString, readQueryPage, syncRouteQuery } =
|
||
useAdminListQuerySync(route, router);
|
||
|
||
const actionLoadingId = ref<number | null>(null);
|
||
const provider = ref(readQueryString("provider"));
|
||
const platform = ref(readQueryString("platform"));
|
||
const platformOrderId = ref(readQueryString("platformOrderId"));
|
||
const processed = ref(readQueryString("processed"));
|
||
const visibility = ref(readQueryString("visibility") || "important");
|
||
const relatedOrderId = ref(readQueryString("relatedOrderId"));
|
||
const dateRange = ref<string[]>(
|
||
[readQueryString("dateFrom"), readQueryString("dateTo")].filter(Boolean)
|
||
);
|
||
const canReplay = hasAdminRole("admin");
|
||
|
||
const {
|
||
loading,
|
||
errorMessage,
|
||
items,
|
||
pagination,
|
||
loadPage: loadEvents,
|
||
} = useAdminListPage<AdminWebhookEventListItem>({
|
||
defaultErrorMessage: "读取 webhook 列表失败",
|
||
fetchPage: (page, pageSize) =>
|
||
fetchAdminWebhookEvents({
|
||
page,
|
||
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: dateRange.value[0] || "",
|
||
dateTo: dateRange.value[1] || "",
|
||
}),
|
||
});
|
||
|
||
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 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 = "";
|
||
dateRange.value = [];
|
||
void applyFilters(1);
|
||
}
|
||
|
||
async function applyFilters(page = 1) {
|
||
await syncRouteQuery(page, {
|
||
provider: provider.value,
|
||
platform: platform.value,
|
||
platformOrderId: platformOrderId.value,
|
||
processed: processed.value,
|
||
visibility: visibility.value,
|
||
relatedOrderId: relatedOrderId.value,
|
||
dateFrom: dateRange.value[0] || "",
|
||
dateTo: dateRange.value[1] || "",
|
||
});
|
||
await loadEvents(page);
|
||
}
|
||
|
||
function formatVisibilityLabel(value: string) {
|
||
return value === "ignored" ? "已忽略" : "重要";
|
||
}
|
||
|
||
onMounted(() => {
|
||
void applyFilters(readQueryPage());
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="webhooks-page list-page">
|
||
<AdminPageHeader
|
||
title="Webhook 日志"
|
||
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">筛选 Webhook</span>
|
||
<span class="card-desc"
|
||
>按服务商、平台、处理状态、可见级别等条件筛选。</span
|
||
>
|
||
</div>
|
||
</template>
|
||
<div class="filter-grid">
|
||
<el-input
|
||
v-model="provider"
|
||
class="filter-control"
|
||
placeholder="服务商,如 91kaquan"
|
||
clearable
|
||
@keyup.enter="applyFilters(1)"
|
||
/>
|
||
<el-input
|
||
v-model="platform"
|
||
class="filter-control"
|
||
placeholder="业务平台,如 kuaishou"
|
||
clearable
|
||
@keyup.enter="applyFilters(1)"
|
||
/>
|
||
<el-select
|
||
v-model="processed"
|
||
class="filter-control"
|
||
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"
|
||
class="filter-control"
|
||
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"
|
||
class="filter-control"
|
||
placeholder="平台订单号"
|
||
clearable
|
||
@keyup.enter="applyFilters(1)"
|
||
/>
|
||
<el-input
|
||
v-model="relatedOrderId"
|
||
class="filter-control"
|
||
placeholder="后台订单 ID"
|
||
clearable
|
||
@keyup.enter="applyFilters(1)"
|
||
/>
|
||
<el-date-picker
|
||
v-model="dateRange"
|
||
class="filter-control"
|
||
type="daterange"
|
||
start-placeholder="开始日期"
|
||
end-placeholder="结束日期"
|
||
range-separator="至"
|
||
value-format="YYYY-MM-DD"
|
||
/>
|
||
<div class="filter-buttons">
|
||
<el-button round @click="resetFilters">重置</el-button>
|
||
<el-button round type="primary" @click="applyFilters(1)"
|
||
>查询</el-button
|
||
>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 摘要 -->
|
||
<div v-if="!loading && items.length > 0" class="summary-strip">
|
||
<span class="summary-title">当前页</span>
|
||
<span class="summary-metric" data-tone="success"
|
||
>处理成功 <strong>{{ summary.processedCount }}</strong></span
|
||
>
|
||
<span class="summary-metric" data-tone="danger"
|
||
>异常/失败 <strong>{{ summary.failedCount }}</strong></span
|
||
>
|
||
<span class="summary-metric" data-tone="primary"
|
||
>重要事件 <strong>{{ summary.importantCount }}</strong></span
|
||
>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-if="errorMessage"
|
||
:title="errorMessage"
|
||
type="error"
|
||
show-icon
|
||
:closable="false"
|
||
class="mt-4"
|
||
/>
|
||
|
||
<!-- 列表 -->
|
||
<el-card
|
||
shadow="never"
|
||
class="section-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="orders-toolbar table-toolbar">
|
||
<strong>Webhook 列表</strong>
|
||
<span
|
||
>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span
|
||
>
|
||
</div>
|
||
|
||
<el-table
|
||
:data="items"
|
||
stripe
|
||
size="small"
|
||
class="orders-table data-table"
|
||
>
|
||
<el-table-column label="事件ID" width="100">
|
||
<template #default="{ row }">
|
||
<RouterLink
|
||
:to="`/admin/webhook-events/${row.eventId}`"
|
||
class="order-no id-link"
|
||
>
|
||
#{{ row.eventId }}
|
||
</RouterLink>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="事件类型" min-width="200">
|
||
<template #default="{ row }">
|
||
<div class="cell-stack">
|
||
<span class="cell-title">{{
|
||
formatAdminWebhookEventType(row.eventType)
|
||
}}</span>
|
||
<div class="cell-meta-row">
|
||
<span class="compact-chip">{{ row.provider || "-" }}</span>
|
||
<span class="compact-chip">{{ row.platform || "-" }}</span>
|
||
<span class="compact-chip compact-chip--muted">{{
|
||
row.shopName || row.shopId || "-"
|
||
}}</span>
|
||
</div>
|
||
<span v-if="row.aopic" class="cell-subline"
|
||
>aopic {{ row.aopic }}</span
|
||
>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="平台订单号" min-width="180">
|
||
<template #default="{ row }">
|
||
<div class="cell-stack">
|
||
<span class="cell-title">{{ row.platformOrderId || "-" }}</span>
|
||
<span v-if="row.totalAmount" class="cell-subline"
|
||
>{{ row.totalAmount }} {{ row.currency }}</span
|
||
>
|
||
<span v-if="row.buyerName || row.buyerId" class="cell-subline"
|
||
>买家 {{ row.buyerName || row.buyerId }}</span
|
||
>
|
||
<RouterLink
|
||
v-if="row.relatedOrderId"
|
||
class="inline-link"
|
||
:to="`/admin/orders/${row.relatedOrderId}`"
|
||
>
|
||
关联订单 #{{ row.relatedOrderId }}
|
||
</RouterLink>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" min-width="150">
|
||
<template #default="{ row }">
|
||
<div class="cell-stack">
|
||
<div class="status-stack">
|
||
<AdminStatusTag
|
||
:status="row.processed ? 'success' : 'failed'"
|
||
/>
|
||
<AdminStatusTag
|
||
:status="row.signatureValid ? 'success' : 'failed'"
|
||
/>
|
||
</div>
|
||
<span class="cell-subline">{{
|
||
formatVisibilityLabel(row.visibilityLevel)
|
||
}}</span>
|
||
<span v-if="row.processError" class="error-inline">{{
|
||
row.processError
|
||
}}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="创建时间" min-width="160">
|
||
<template #default="{ row }">
|
||
<div class="cell-stack">
|
||
<span class="cell-title">{{
|
||
formatAdminDateTime(row.createdAt)
|
||
}}</span>
|
||
<span class="cell-subline">#{{ row.eventId }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="130" align="center">
|
||
<template #default="{ row }">
|
||
<div class="action-stack">
|
||
<RouterLink
|
||
:to="`/admin/webhook-events/${row.eventId}`"
|
||
class="detail-link"
|
||
>详情</RouterLink
|
||
>
|
||
<el-button
|
||
v-if="canReplay"
|
||
:loading="actionLoadingId === row.eventId"
|
||
size="small"
|
||
@click="submitReplay(row)"
|
||
>重放</el-button
|
||
>
|
||
</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="applyFilters"
|
||
/>
|
||
</template>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
@import "../../../styles/admin-list-pages.css";
|
||
|
||
.filter-grid {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
|
||
.error-inline {
|
||
display: block;
|
||
color: var(--color-danger);
|
||
font-size: var(--text-xs);
|
||
}
|
||
|
||
.inline-link {
|
||
color: var(--color-primary-dark);
|
||
font-size: var(--text-xs);
|
||
font-weight: 700;
|
||
text-decoration: none;
|
||
}
|
||
.inline-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.action-stack {
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
</style>
|