feat(frontend): add P2 quality gates

This commit is contained in:
yml2213
2026-05-15 17:35:50 +08:00
parent a65c0713fb
commit 787b01749c
62 changed files with 4907 additions and 1355 deletions
@@ -89,11 +89,15 @@ onMounted(loadDetail)
<template>
<section class="admin-panel">
<AdminPageHeader
title="Webhook 详情"
description="查看原始请求并手动重放处理流程。"
>
<el-button v-if="hasAdminRole('admin')" :loading="replayLoading" round type="primary" @click="submitReplay">重放处理</el-button>
<AdminPageHeader title="Webhook 详情" description="查看原始请求并手动重放处理流程。">
<el-button
v-if="hasAdminRole('admin')"
:loading="replayLoading"
round
type="primary"
@click="submitReplay"
>重放处理</el-button
>
</AdminPageHeader>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
@@ -131,7 +135,9 @@ onMounted(loadDetail)
<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>
<RouterLink class="inline-link" :to="`/admin/orders/${detail.relatedOrderId}`">{{
detail.relatedOrderId
}}</RouterLink>
</strong>
<strong v-else>-</strong>
<span class="summary-note">任务数: {{ detail.taskCount }}</span>
@@ -175,12 +181,17 @@ onMounted(loadDetail)
<h3>最近一次重放结果</h3>
<p class="result-copy">
{{ lastReplayResult.provider }} / {{ lastReplayResult.platform }}
<template v-if="lastReplayResult.platformRaw && lastReplayResult.platformRaw !== lastReplayResult.platform">
<template
v-if="
lastReplayResult.platformRaw &&
lastReplayResult.platformRaw !== lastReplayResult.platform
"
>
· 原始平台 {{ lastReplayResult.platformRaw }}
</template>
· 平台订单 {{ lastReplayResult.platformOrderId }}
· 事件 {{ formatAdminWebhookEventType(lastReplayResult.eventType) }}
· 任务数 {{ lastReplayResult.taskCount }}
· 平台订单 {{ lastReplayResult.platformOrderId }} · 事件
{{ formatAdminWebhookEventType(lastReplayResult.eventType) }} · 任务数
{{ lastReplayResult.taskCount }}
</p>
<el-table :data="lastReplayResult.tasks" size="small">
<el-table-column prop="taskId" label="任务 ID" />
@@ -1,38 +1,32 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAdminListPage } from "@/composables/useAdminListPage";
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";
import { useAdminListPage } from '@/composables/useAdminListPage'
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 route = useRoute()
const router = useRouter()
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 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");
[readQueryString('dateFrom'), readQueryString('dateTo')].filter(Boolean),
)
const canReplay = hasAdminRole('admin')
const {
loading,
@@ -41,7 +35,7 @@ const {
pagination,
loadPage: loadEvents,
} = useAdminListPage<AdminWebhookEventListItem>({
defaultErrorMessage: "读取 webhook 列表失败",
defaultErrorMessage: '读取 webhook 列表失败',
fetchPage: (page, pageSize) =>
fetchAdminWebhookEvents({
page,
@@ -52,107 +46,99 @@ const {
processed: processed.value.trim(),
visibility: visibility.value.trim(),
relatedOrderId: relatedOrderId.value.trim(),
dateFrom: dateRange.value[0] || "",
dateTo: dateRange.value[1] || "",
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,
}));
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: "取消",
});
await showConfirm(`确认重放 webhook #${item.eventId} 吗?`, '确认重放', {
type: 'warning',
confirmButtonText: '继续重放',
cancelButtonText: '取消',
})
} catch {
return;
return
}
actionLoadingId.value = item.eventId;
actionLoadingId.value = item.eventId
try {
await replayAdminWebhookEvent(item.eventId);
showSuccess("Webhook 已重放");
await loadEvents();
await replayAdminWebhookEvent(item.eventId)
showSuccess('Webhook 已重放')
await loadEvents()
} catch (error) {
showError(error instanceof Error ? error.message : "重放 webhook 失败");
showError(error instanceof Error ? error.message : '重放 webhook 失败')
} finally {
actionLoadingId.value = null;
actionLoadingId.value = null
}
}
function resetFilters() {
provider.value = "";
platform.value = "";
platformOrderId.value = "";
processed.value = "";
visibility.value = "important";
relatedOrderId.value = "";
dateRange.value = [];
void applyFilters(1);
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);
await loadEvents(page);
await syncRouteQuery(page)
await loadEvents(page)
}
function readQueryString(key: string) {
const value = route.query[key];
const rawValue = Array.isArray(value) ? value[0] : value;
return typeof rawValue === "string" ? rawValue.trim() : "";
const value = route.query[key]
const rawValue = Array.isArray(value) ? value[0] : value
return typeof rawValue === 'string' ? rawValue.trim() : ''
}
function readQueryPage() {
const page = Number(readQueryString("page"));
return Number.isInteger(page) && page > 0 ? page : 1;
const page = Number(readQueryString('page'))
return Number.isInteger(page) && page > 0 ? page : 1
}
function syncRouteQuery(page: number) {
const query: Record<string, string> = {};
addQueryValue(query, "provider", provider.value);
addQueryValue(query, "platform", platform.value);
addQueryValue(query, "platformOrderId", platformOrderId.value);
addQueryValue(query, "processed", processed.value);
addQueryValue(query, "visibility", visibility.value);
addQueryValue(query, "relatedOrderId", relatedOrderId.value);
addQueryValue(query, "dateFrom", dateRange.value[0] || "");
addQueryValue(query, "dateTo", dateRange.value[1] || "");
const query: Record<string, string> = {}
addQueryValue(query, 'provider', provider.value)
addQueryValue(query, 'platform', platform.value)
addQueryValue(query, 'platformOrderId', platformOrderId.value)
addQueryValue(query, 'processed', processed.value)
addQueryValue(query, 'visibility', visibility.value)
addQueryValue(query, 'relatedOrderId', relatedOrderId.value)
addQueryValue(query, 'dateFrom', dateRange.value[0] || '')
addQueryValue(query, 'dateTo', dateRange.value[1] || '')
if (page > 1) {
query.page = String(page);
query.page = String(page)
}
return router.replace({ query });
return router.replace({ query })
}
function addQueryValue(
query: Record<string, string>,
key: string,
value: string
) {
const trimmed = value.trim();
function addQueryValue(query: Record<string, string>, key: string, value: string) {
const trimmed = value.trim()
if (trimmed) {
query[key] = trimmed;
query[key] = trimmed
}
}
function formatVisibilityLabel(value: string) {
return value === "ignored" ? "已忽略" : "重要";
return value === 'ignored' ? '已忽略' : '重要'
}
onMounted(() => {
void applyFilters(readQueryPage());
});
void applyFilters(readQueryPage())
})
</script>
<template>
@@ -171,9 +157,7 @@ onMounted(() => {
<template #header>
<div class="card-header">
<span class="card-title">筛选 Webhook</span>
<span class="card-desc"
>按服务商平台处理状态可见级别等条件筛选</span
>
<span class="card-desc">按服务商平台处理状态可见级别等条件筛选</span>
</div>
</template>
<div class="filter-grid">
@@ -191,12 +175,7 @@ onMounted(() => {
clearable
@keyup.enter="applyFilters(1)"
/>
<el-select
v-model="processed"
class="filter-control"
placeholder="处理结果"
clearable
>
<el-select v-model="processed" class="filter-control" placeholder="处理结果" clearable>
<el-option
v-for="opt in adminWebhookProcessedOptions"
:key="opt.value"
@@ -204,12 +183,7 @@ onMounted(() => {
:value="opt.value"
/>
</el-select>
<el-select
v-model="visibility"
class="filter-control"
placeholder="可见级别"
clearable
>
<el-select v-model="visibility" class="filter-control" placeholder="可见级别" clearable>
<el-option
v-for="opt in adminWebhookVisibilityOptions"
:key="opt.value"
@@ -242,9 +216,7 @@ onMounted(() => {
/>
<div class="filter-buttons">
<el-button round @click="resetFilters">重置</el-button>
<el-button round type="primary" @click="applyFilters(1)"
>查询</el-button
>
<el-button round type="primary" @click="applyFilters(1)">查询</el-button>
</div>
</div>
</el-card>
@@ -280,32 +252,19 @@ onMounted(() => {
element-loading-text="Webhook 列表加载中"
>
<template v-if="items.length === 0 && !loading">
<el-empty
description="当前筛选条件下暂无 Webhook 事件"
:image-size="60"
/>
<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
>
<span> {{ pagination.page }} 当前展示 {{ items.length }} </span>
</div>
<el-table
:data="items"
stripe
size="small"
class="orders-table data-table"
>
<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"
>
<RouterLink :to="`/admin/webhook-events/${row.eventId}`" class="order-no id-link">
#{{ row.eventId }}
</RouterLink>
</template>
@@ -313,26 +272,22 @@ onMounted(() => {
<el-table-column label="事件类型" min-width="200">
<template #default="{ row }">
<div class="cell-stack">
<span class="cell-title">{{
formatAdminWebhookEventType(row.eventType)
}}</span>
<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">{{ row.provider || '-' }}</span>
<span class="compact-chip">{{ row.platform || '-' }}</span>
<span class="compact-chip compact-chip--muted">{{
row.shopName || row.shopId || "-"
row.shopName || row.shopId || '-'
}}</span>
</div>
<span v-if="row.aopic" class="cell-subline"
>aopic {{ row.aopic }}</span
>
<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 class="cell-title">{{ row.platformOrderId || '-' }}</span>
<span v-if="row.totalAmount" class="cell-subline"
>{{ row.totalAmount }} {{ row.currency }}</span
>
@@ -353,28 +308,18 @@ onMounted(() => {
<template #default="{ row }">
<div class="cell-stack">
<div class="status-stack">
<AdminStatusTag
:status="row.processed ? 'success' : 'failed'"
/>
<AdminStatusTag
:status="row.signatureValid ? 'success' : 'failed'"
/>
<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>
<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-title">{{ formatAdminDateTime(row.createdAt) }}</span>
<span class="cell-subline">#{{ row.eventId }}</span>
</div>
</template>
@@ -382,9 +327,7 @@ onMounted(() => {
<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 :to="`/admin/webhook-events/${row.eventId}`" class="detail-link"
>详情</RouterLink
>
<el-button
@@ -414,7 +357,7 @@ onMounted(() => {
</template>
<style scoped>
@import "../../../styles/admin-list-pages.css";
@import '../../../styles/admin-list-pages.css';
.filter-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));