1582 lines
40 KiB
Vue
1582 lines
40 KiB
Vue
<script lang="ts" setup>
|
||
import type { Dayjs } from 'dayjs';
|
||
|
||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||
|
||
import type { OpenApiCenterApi } from '#/api/manager/open-api';
|
||
|
||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||
|
||
import { Page } from '@vben/common-ui';
|
||
import { IconifyIcon } from '@vben/icons';
|
||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||
|
||
import {
|
||
Button,
|
||
Card,
|
||
DatePicker,
|
||
Form,
|
||
FormItem,
|
||
Input,
|
||
message,
|
||
Modal,
|
||
Select,
|
||
Switch,
|
||
Table,
|
||
Tabs,
|
||
Tag,
|
||
Tooltip,
|
||
} from 'ant-design-vue';
|
||
import dayjs from 'dayjs';
|
||
|
||
import {
|
||
createOpenApiEndpoint,
|
||
getOpenApiConsumers,
|
||
getOpenApiEndpoints,
|
||
getOpenApiOverview,
|
||
getOpenApiUsage,
|
||
updateOpenApiConsumer,
|
||
updateOpenApiEndpoint,
|
||
} from '#/api/manager/open-api';
|
||
|
||
const RangePicker = DatePicker.RangePicker;
|
||
const activeTab = ref('overview');
|
||
const loading = ref(false);
|
||
const dateRange = ref<[Dayjs, Dayjs]>([
|
||
dayjs().subtract(6, 'day').startOf('day'),
|
||
dayjs().endOf('day'),
|
||
]);
|
||
const serviceCode = ref<string>();
|
||
const category = ref<string>();
|
||
const callerType = ref<string>();
|
||
const selectedEndpointId = ref<string>();
|
||
|
||
const overview = ref<OpenApiCenterApi.Overview>({
|
||
activeConsumers: 0,
|
||
activeIps: 0,
|
||
anonymousCalls: 0,
|
||
authenticatedCalls: 0,
|
||
categoryDistribution: [],
|
||
successRate: 100,
|
||
topConsumers: [],
|
||
topEndpoints: [],
|
||
totalCalls: 0,
|
||
trend: [],
|
||
unauthorizedCalls: 0,
|
||
});
|
||
const endpoints = ref<OpenApiCenterApi.Endpoint[]>([]);
|
||
const consumers = ref<OpenApiCenterApi.Consumer[]>([]);
|
||
const usage = ref<OpenApiCenterApi.Usage[]>([]);
|
||
|
||
const trendChartRef = ref<EchartsUIType>();
|
||
const distributionChartRef = ref<EchartsUIType>();
|
||
const rankingChartRef = ref<EchartsUIType>();
|
||
const { renderEcharts: renderTrend } = useEcharts(trendChartRef);
|
||
const { renderEcharts: renderDistribution } = useEcharts(distributionChartRef);
|
||
const { renderEcharts: renderRanking } = useEcharts(rankingChartRef);
|
||
|
||
const serviceOptions = [
|
||
{ label: '票通平台', value: 'invoice' },
|
||
{ label: 'AI 后端', value: 'ai-fastapi' },
|
||
{ label: '核心 Ktor', value: 'ktor-core' },
|
||
{ label: 'F10 公开页', value: 'f10' },
|
||
];
|
||
const categoryOptions = computed(() =>
|
||
[...new Set(endpoints.value.map((item) => item.category))]
|
||
.filter(Boolean)
|
||
.map((value) => ({ label: value, value })),
|
||
);
|
||
const endpointOptions = computed(() =>
|
||
endpoints.value.map((item) => ({
|
||
label: `${item.name} · ${item.httpMethod}`,
|
||
value: item.id,
|
||
})),
|
||
);
|
||
const metricCards = computed(() => [
|
||
{
|
||
accent: '#3b82f6',
|
||
icon: 'lucide:activity',
|
||
label: '总调用量',
|
||
note: '所选周期内的网关请求',
|
||
value: overview.value.totalCalls,
|
||
},
|
||
{
|
||
accent: '#0f9f7f',
|
||
icon: 'lucide:badge-check',
|
||
label: '认证调用',
|
||
note: `${overview.value.activeConsumers} 个活跃调用方`,
|
||
value: overview.value.authenticatedCalls,
|
||
},
|
||
{
|
||
accent: '#f59e0b',
|
||
icon: 'lucide:scan-face',
|
||
label: '匿名调用',
|
||
note: `${overview.value.activeIps} 个来源 IP`,
|
||
value: overview.value.anonymousCalls,
|
||
},
|
||
{
|
||
accent: '#ef4444',
|
||
icon: 'lucide:shield-alert',
|
||
label: '认证失败',
|
||
note: 'HTTP 401 / 403',
|
||
value: overview.value.unauthorizedCalls,
|
||
},
|
||
{
|
||
accent: '#8b5cf6',
|
||
icon: 'lucide:gauge',
|
||
label: '请求成功率',
|
||
note: 'HTTP 2xx / 3xx',
|
||
suffix: '%',
|
||
value: overview.value.successRate,
|
||
},
|
||
]);
|
||
|
||
const endpointColumns = [
|
||
{ key: 'name', title: '接口资产', width: 250 },
|
||
{ key: 'service', title: '所属服务', width: 130 },
|
||
{ key: 'path', title: '请求路径', width: 410 },
|
||
{ dataIndex: 'recentCalls', key: 'recentCalls', title: '近 24h', width: 100 },
|
||
{
|
||
dataIndex: 'recentConsumers',
|
||
key: 'recentConsumers',
|
||
title: '调用方',
|
||
width: 90,
|
||
},
|
||
{ key: 'lastCalledAt', title: '最后调用', width: 165 },
|
||
{ key: 'status', title: '状态', width: 100 },
|
||
{ key: 'action', title: '', width: 76 },
|
||
];
|
||
const consumerColumns = [
|
||
{ key: 'identity', title: '调用方', width: 260 },
|
||
{ dataIndex: 'customId', key: 'customId', title: 'Custom ID', width: 180 },
|
||
{ dataIndex: 'callCount', key: 'callCount', title: '累计调用', width: 120 },
|
||
{
|
||
dataIndex: 'endpointCount',
|
||
key: 'endpointCount',
|
||
title: '接口数',
|
||
width: 90,
|
||
},
|
||
{ dataIndex: 'recentIp', key: 'recentIp', title: '最近 IP', width: 150 },
|
||
{ key: 'lastSeenAt', title: '最后调用', width: 170 },
|
||
{ key: 'status', title: '状态', width: 100 },
|
||
{ key: 'action', title: '', width: 76 },
|
||
];
|
||
const usageColumns = [
|
||
{ key: 'bucket', title: '时间', width: 165 },
|
||
{ key: 'endpoint', title: '接口', width: 230 },
|
||
{ key: 'caller', title: '调用方', width: 210 },
|
||
{ dataIndex: 'clientIp', key: 'clientIp', title: '来源 IP', width: 150 },
|
||
{ dataIndex: 'callCount', key: 'callCount', title: '调用量', width: 100 },
|
||
{ dataIndex: 'successCount', key: 'successCount', title: '成功', width: 90 },
|
||
{ dataIndex: 'failureCount', key: 'failureCount', title: '失败', width: 90 },
|
||
{
|
||
dataIndex: 'unauthorizedCount',
|
||
key: 'unauthorizedCount',
|
||
title: '认证失败',
|
||
width: 100,
|
||
},
|
||
];
|
||
|
||
const endpointModalOpen = ref(false);
|
||
const endpointSaving = ref(false);
|
||
const editingEndpointId = ref<string>();
|
||
const endpointForm = reactive<OpenApiCenterApi.SaveEndpoint>({
|
||
category: '',
|
||
description: '',
|
||
httpMethod: 'POST',
|
||
kongRouteId: '',
|
||
kongServiceId: '',
|
||
monitorEnabled: true,
|
||
name: '',
|
||
owner: '',
|
||
pathTemplate: '',
|
||
serviceCode: 'invoice',
|
||
status: 'enabled',
|
||
});
|
||
|
||
const consumerModalOpen = ref(false);
|
||
const consumerSaving = ref(false);
|
||
const editingConsumer = ref<OpenApiCenterApi.Consumer>();
|
||
const consumerForm = reactive({
|
||
description: '',
|
||
displayName: '',
|
||
status: 'active',
|
||
});
|
||
|
||
function buildQuery(): OpenApiCenterApi.Query {
|
||
return {
|
||
callerType: callerType.value,
|
||
category: category.value,
|
||
endpointId: selectedEndpointId.value,
|
||
from: dateRange.value[0].toISOString(),
|
||
serviceCode: serviceCode.value,
|
||
to: dateRange.value[1].toISOString(),
|
||
};
|
||
}
|
||
|
||
async function refreshAll() {
|
||
loading.value = true;
|
||
try {
|
||
const query = buildQuery();
|
||
const [overviewData, endpointData, consumerData, usageData] =
|
||
await Promise.all([
|
||
getOpenApiOverview(query),
|
||
getOpenApiEndpoints(),
|
||
getOpenApiConsumers(),
|
||
getOpenApiUsage(query),
|
||
]);
|
||
overview.value = overviewData;
|
||
endpoints.value = endpointData;
|
||
consumers.value = consumerData;
|
||
usage.value = usageData;
|
||
await nextTick();
|
||
renderCharts();
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function renderCharts() {
|
||
const trend = overview.value.trend;
|
||
renderTrend({
|
||
animationDuration: 650,
|
||
color: ['#2563eb', '#10b981', '#f59e0b', '#ef4444'],
|
||
grid: { bottom: 20, containLabel: true, left: 8, right: 14, top: 52 },
|
||
legend: {
|
||
icon: 'roundRect',
|
||
itemHeight: 7,
|
||
itemWidth: 18,
|
||
right: 4,
|
||
textStyle: { color: '#8492a6' },
|
||
top: 4,
|
||
},
|
||
series: [
|
||
{
|
||
areaStyle: {
|
||
color: {
|
||
colorStops: [
|
||
{ color: 'rgba(37,99,235,.25)', offset: 0 },
|
||
{ color: 'rgba(37,99,235,0)', offset: 1 },
|
||
],
|
||
type: 'linear',
|
||
x: 0,
|
||
x2: 0,
|
||
y: 0,
|
||
y2: 1,
|
||
},
|
||
},
|
||
data: trend.map((item) => item.calls),
|
||
emphasis: { focus: 'series' },
|
||
name: '总调用',
|
||
showSymbol: false,
|
||
smooth: 0.35,
|
||
type: 'line',
|
||
},
|
||
{
|
||
data: trend.map((item) => item.authenticatedCalls),
|
||
name: '认证调用',
|
||
showSymbol: false,
|
||
smooth: 0.35,
|
||
type: 'line',
|
||
},
|
||
{
|
||
data: trend.map((item) => item.anonymousCalls),
|
||
name: '匿名调用',
|
||
showSymbol: false,
|
||
smooth: 0.35,
|
||
type: 'line',
|
||
},
|
||
{
|
||
data: trend.map((item) => item.failures),
|
||
name: '失败',
|
||
showSymbol: false,
|
||
smooth: 0.35,
|
||
type: 'line',
|
||
},
|
||
],
|
||
tooltip: {
|
||
axisPointer: { type: 'line' },
|
||
backgroundColor: 'rgba(15,23,42,.94)',
|
||
borderWidth: 0,
|
||
textStyle: { color: '#fff' },
|
||
trigger: 'axis',
|
||
},
|
||
xAxis: {
|
||
axisLabel: {
|
||
color: '#8492a6',
|
||
formatter: (value: string) =>
|
||
dayjs(value).format(trend.length > 24 ? 'MM-DD' : 'MM-DD HH:mm'),
|
||
hideOverlap: true,
|
||
},
|
||
axisLine: { lineStyle: { color: 'rgba(148,163,184,.2)' } },
|
||
axisTick: { show: false },
|
||
boundaryGap: false,
|
||
data: trend.map((item) => item.bucket),
|
||
type: 'category',
|
||
},
|
||
yAxis: {
|
||
axisLabel: { color: '#8492a6' },
|
||
splitLine: { lineStyle: { color: 'rgba(148,163,184,.12)' } },
|
||
type: 'value',
|
||
},
|
||
});
|
||
|
||
renderDistribution({
|
||
color: ['#2563eb', '#10b981', '#8b5cf6', '#f59e0b', '#06b6d4', '#64748b'],
|
||
legend: {
|
||
bottom: 0,
|
||
icon: 'circle',
|
||
itemHeight: 8,
|
||
itemWidth: 8,
|
||
textStyle: { color: '#8492a6' },
|
||
},
|
||
series: [
|
||
{
|
||
center: ['50%', '43%'],
|
||
data: overview.value.categoryDistribution.map((item) => ({
|
||
name: item.name,
|
||
value: item.value,
|
||
})),
|
||
itemStyle: { borderColor: 'rgba(255,255,255,.75)', borderWidth: 2 },
|
||
label: { show: false },
|
||
radius: ['52%', '76%'],
|
||
type: 'pie',
|
||
},
|
||
],
|
||
tooltip: {
|
||
backgroundColor: 'rgba(15,23,42,.94)',
|
||
borderWidth: 0,
|
||
textStyle: { color: '#fff' },
|
||
trigger: 'item',
|
||
},
|
||
});
|
||
|
||
const ranking = [...overview.value.topEndpoints].reverse();
|
||
renderRanking({
|
||
color: ['#2563eb'],
|
||
grid: { bottom: 8, containLabel: true, left: 8, right: 26, top: 8 },
|
||
series: [
|
||
{
|
||
barMaxWidth: 14,
|
||
data: ranking.map((item) => ({
|
||
itemStyle: {
|
||
borderRadius: [0, 8, 8, 0],
|
||
color: {
|
||
colorStops: [
|
||
{ color: '#60a5fa', offset: 0 },
|
||
{ color: '#2563eb', offset: 1 },
|
||
],
|
||
type: 'linear',
|
||
x: 0,
|
||
x2: 1,
|
||
y: 0,
|
||
y2: 0,
|
||
},
|
||
},
|
||
value: item.value,
|
||
})),
|
||
label: { color: '#64748b', position: 'right', show: true },
|
||
type: 'bar',
|
||
},
|
||
],
|
||
tooltip: { trigger: 'axis' },
|
||
xAxis: { show: false, type: 'value' },
|
||
yAxis: {
|
||
axisLabel: {
|
||
color: '#64748b',
|
||
formatter: (value: string) =>
|
||
value.length > 10 ? `${value.slice(0, 10)}…` : value,
|
||
},
|
||
axisLine: { show: false },
|
||
axisTick: { show: false },
|
||
data: ranking.map((item) => item.name),
|
||
type: 'category',
|
||
},
|
||
});
|
||
}
|
||
|
||
function resetFilters() {
|
||
dateRange.value = [
|
||
dayjs().subtract(6, 'day').startOf('day'),
|
||
dayjs().endOf('day'),
|
||
];
|
||
serviceCode.value = undefined;
|
||
category.value = undefined;
|
||
callerType.value = undefined;
|
||
selectedEndpointId.value = undefined;
|
||
refreshAll();
|
||
}
|
||
|
||
function openEndpointModal(row?: OpenApiCenterApi.Endpoint) {
|
||
editingEndpointId.value = row?.id;
|
||
Object.assign(endpointForm, {
|
||
category: row?.category ?? '',
|
||
description: row?.description ?? '',
|
||
httpMethod: row?.httpMethod ?? 'POST',
|
||
kongRouteId: row?.kongRouteId ?? '',
|
||
kongServiceId: row?.kongServiceId ?? '',
|
||
monitorEnabled: row?.monitorEnabled ?? true,
|
||
name: row?.name ?? '',
|
||
owner: row?.owner ?? '',
|
||
pathTemplate: row?.pathTemplate ?? '',
|
||
serviceCode: row?.serviceCode ?? 'invoice',
|
||
status: row?.status ?? 'enabled',
|
||
});
|
||
endpointModalOpen.value = true;
|
||
}
|
||
|
||
function editEndpointRecord(row: Record<string, unknown>) {
|
||
openEndpointModal(row as unknown as OpenApiCenterApi.Endpoint);
|
||
}
|
||
|
||
async function saveEndpoint() {
|
||
if (
|
||
!endpointForm.name.trim() ||
|
||
!endpointForm.category.trim() ||
|
||
!endpointForm.pathTemplate.trim()
|
||
) {
|
||
message.warning('请完整填写接口名称、分类和请求路径');
|
||
return;
|
||
}
|
||
endpointSaving.value = true;
|
||
try {
|
||
await (editingEndpointId.value
|
||
? updateOpenApiEndpoint(editingEndpointId.value, { ...endpointForm })
|
||
: createOpenApiEndpoint({ ...endpointForm }));
|
||
message.success(
|
||
editingEndpointId.value ? '接口资产已更新' : '接口资产已创建',
|
||
);
|
||
endpointModalOpen.value = false;
|
||
await refreshAll();
|
||
} finally {
|
||
endpointSaving.value = false;
|
||
}
|
||
}
|
||
|
||
function openConsumerModal(row: OpenApiCenterApi.Consumer) {
|
||
editingConsumer.value = row;
|
||
Object.assign(consumerForm, {
|
||
description: row.description,
|
||
displayName: row.displayName || row.username,
|
||
status: row.status,
|
||
});
|
||
consumerModalOpen.value = true;
|
||
}
|
||
|
||
function editConsumerRecord(row: Record<string, unknown>) {
|
||
openConsumerModal(row as unknown as OpenApiCenterApi.Consumer);
|
||
}
|
||
|
||
async function saveConsumer() {
|
||
if (!editingConsumer.value) return;
|
||
consumerSaving.value = true;
|
||
try {
|
||
await updateOpenApiConsumer(editingConsumer.value.id, { ...consumerForm });
|
||
message.success('调用方资料已更新');
|
||
consumerModalOpen.value = false;
|
||
await refreshAll();
|
||
} finally {
|
||
consumerSaving.value = false;
|
||
}
|
||
}
|
||
|
||
function formatNumber(value: number) {
|
||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(
|
||
value || 0,
|
||
);
|
||
}
|
||
|
||
function formatTime(value?: string) {
|
||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '尚无调用';
|
||
}
|
||
|
||
function serviceName(code: string) {
|
||
return serviceOptions.find((item) => item.value === code)?.label ?? code;
|
||
}
|
||
|
||
function methodColor(method: string) {
|
||
return (
|
||
{
|
||
DELETE: 'red',
|
||
GET: 'blue',
|
||
PATCH: 'orange',
|
||
POST: 'green',
|
||
PUT: 'purple',
|
||
}[method] ?? 'default'
|
||
);
|
||
}
|
||
|
||
function usageRowKey(record: OpenApiCenterApi.Usage) {
|
||
return [
|
||
record.bucket,
|
||
record.endpointId,
|
||
record.callerType,
|
||
record.consumerId || record.clientIp,
|
||
].join('-');
|
||
}
|
||
|
||
watch([trendChartRef, distributionChartRef, rankingChartRef], () => {
|
||
if (overview.value) renderCharts();
|
||
});
|
||
|
||
onMounted(refreshAll);
|
||
</script>
|
||
|
||
<template>
|
||
<Page auto-content-height class="open-api-page">
|
||
<div class="hero-panel">
|
||
<div class="hero-orb hero-orb-one"></div>
|
||
<div class="hero-orb hero-orb-two"></div>
|
||
<div class="hero-content">
|
||
<div>
|
||
<div class="hero-kicker">
|
||
<span class="live-dot"></span>
|
||
API OPERATIONS CENTER
|
||
</div>
|
||
<h1>开放接口中心</h1>
|
||
<p>统一洞察票通、人工智能与业务开放能力,让每一次调用都清晰可见。</p>
|
||
</div>
|
||
<div class="hero-actions">
|
||
<div class="health-pill">
|
||
<IconifyIcon icon="lucide:shield-check" />
|
||
<span>Kong 统一采集</span>
|
||
</div>
|
||
<Button ghost size="large" @click="refreshAll">
|
||
<template #icon>
|
||
<IconifyIcon
|
||
:class="{ spinning: loading }"
|
||
icon="lucide:refresh-cw"
|
||
/>
|
||
</template>
|
||
刷新数据
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Card :bordered="false" class="filter-card">
|
||
<div class="filter-bar">
|
||
<div class="filter-title">
|
||
<IconifyIcon icon="lucide:sliders-horizontal" />
|
||
<span>统计范围</span>
|
||
</div>
|
||
<RangePicker
|
||
v-model:value="dateRange"
|
||
:allow-clear="false"
|
||
class="range-picker"
|
||
show-time
|
||
/>
|
||
<Select
|
||
v-model:value="serviceCode"
|
||
allow-clear
|
||
class="filter-select"
|
||
:options="serviceOptions"
|
||
placeholder="全部服务"
|
||
/>
|
||
<Select
|
||
v-model:value="category"
|
||
allow-clear
|
||
class="filter-select"
|
||
:options="categoryOptions"
|
||
placeholder="全部分类"
|
||
/>
|
||
<Select
|
||
v-model:value="callerType"
|
||
allow-clear
|
||
class="filter-select"
|
||
:options="[
|
||
{ label: '已认证调用', value: 'consumer' },
|
||
{ label: '匿名调用', value: 'anonymous' },
|
||
]"
|
||
placeholder="全部调用方"
|
||
/>
|
||
<Button type="primary" @click="refreshAll">
|
||
<template #icon><IconifyIcon icon="lucide:search" /></template>
|
||
应用筛选
|
||
</Button>
|
||
<Button type="text" @click="resetFilters">重置</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
<div class="metric-grid">
|
||
<Card
|
||
v-for="item in metricCards"
|
||
:key="item.label"
|
||
:bordered="false"
|
||
class="metric-card"
|
||
>
|
||
<div class="metric-head">
|
||
<div
|
||
class="metric-icon"
|
||
:style="{ background: `${item.accent}16`, color: item.accent }"
|
||
>
|
||
<IconifyIcon :icon="item.icon" />
|
||
</div>
|
||
<span class="metric-label">{{ item.label }}</span>
|
||
</div>
|
||
<div class="metric-value">
|
||
{{ formatNumber(item.value) }}<small>{{ item.suffix }}</small>
|
||
</div>
|
||
<div class="metric-note">{{ item.note }}</div>
|
||
<div class="metric-line" :style="{ background: item.accent }"></div>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card :bordered="false" class="content-card">
|
||
<Tabs v-model:active-key="activeTab" class="center-tabs">
|
||
<Tabs.TabPane key="overview">
|
||
<template #tab>
|
||
<span class="tab-label"
|
||
><IconifyIcon
|
||
icon="lucide:chart-no-axes-combined"
|
||
/>运行总览</span
|
||
>
|
||
</template>
|
||
<div class="overview-grid">
|
||
<section class="chart-panel chart-panel-wide">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<span class="panel-kicker">CALL VOLUME</span>
|
||
<h3>调用趋势</h3>
|
||
</div>
|
||
<Tag color="blue">成功率 {{ overview.successRate }}%</Tag>
|
||
</div>
|
||
<EchartsUI ref="trendChartRef" class="trend-chart" />
|
||
</section>
|
||
<section class="chart-panel">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<span class="panel-kicker">PORTFOLIO</span>
|
||
<h3>能力分布</h3>
|
||
</div>
|
||
</div>
|
||
<EchartsUI ref="distributionChartRef" class="side-chart" />
|
||
</section>
|
||
<section class="chart-panel">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<span class="panel-kicker">TOP ENDPOINTS</span>
|
||
<h3>热门接口</h3>
|
||
</div>
|
||
</div>
|
||
<EchartsUI ref="rankingChartRef" class="side-chart" />
|
||
</section>
|
||
<section class="chart-panel">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<span class="panel-kicker">TOP CALLERS</span>
|
||
<h3>活跃调用方</h3>
|
||
</div>
|
||
</div>
|
||
<div class="caller-ranking">
|
||
<div
|
||
v-for="(item, index) in overview.topConsumers"
|
||
:key="item.key"
|
||
class="caller-row"
|
||
>
|
||
<span class="rank-index" :class="{ podium: index < 3 }">{{
|
||
index + 1
|
||
}}</span>
|
||
<div class="caller-avatar">
|
||
<IconifyIcon
|
||
:icon="
|
||
item.key.startsWith('ip:')
|
||
? 'lucide:globe-2'
|
||
: 'lucide:building-2'
|
||
"
|
||
/>
|
||
</div>
|
||
<div class="caller-copy">
|
||
<strong>{{ item.name }}</strong>
|
||
<span>{{ item.secondary || '开放接口调用方' }}</span>
|
||
</div>
|
||
<b>{{ formatNumber(item.value) }}</b>
|
||
</div>
|
||
<div
|
||
v-if="overview.topConsumers.length === 0"
|
||
class="empty-state"
|
||
>
|
||
<IconifyIcon icon="lucide:radio-tower" />
|
||
<span>等待 Kong 推送首批调用数据</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</Tabs.TabPane>
|
||
|
||
<Tabs.TabPane key="endpoints">
|
||
<template #tab>
|
||
<span class="tab-label"
|
||
><IconifyIcon icon="lucide:boxes" />接口资产</span
|
||
>
|
||
</template>
|
||
<div class="table-toolbar">
|
||
<div>
|
||
<h3>接口资产目录</h3>
|
||
<p>维护路径模板、业务分类与 Kong 路由映射。</p>
|
||
</div>
|
||
<Button type="primary" @click="openEndpointModal()">
|
||
<template #icon><IconifyIcon icon="lucide:plus" /></template>
|
||
新增接口
|
||
</Button>
|
||
</div>
|
||
<Table
|
||
:columns="endpointColumns"
|
||
:data-source="endpoints"
|
||
:loading="loading"
|
||
:pagination="{ pageSize: 10, showSizeChanger: true }"
|
||
:scroll="{ x: 1400 }"
|
||
row-key="id"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'name'">
|
||
<div class="asset-name">
|
||
<div class="asset-icon">
|
||
<IconifyIcon icon="lucide:webhook" />
|
||
</div>
|
||
<div>
|
||
<strong>{{ record.name }}</strong>
|
||
<span>{{ record.category }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'service'">
|
||
<Tag>{{ serviceName(record.serviceCode) }}</Tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'path'">
|
||
<div class="path-cell">
|
||
<Tag :color="methodColor(record.httpMethod)">
|
||
{{ record.httpMethod }}
|
||
</Tag>
|
||
<Tooltip :title="record.pathTemplate">
|
||
<code>{{ record.pathTemplate }}</code>
|
||
</Tooltip>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'lastCalledAt'">
|
||
<span class="muted-text">{{
|
||
formatTime(record.lastCalledAt)
|
||
}}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'status'">
|
||
<Tag
|
||
:color="
|
||
record.monitorEnabled && record.status === 'enabled'
|
||
? 'success'
|
||
: 'default'
|
||
"
|
||
>
|
||
{{
|
||
record.monitorEnabled && record.status === 'enabled'
|
||
? '监控中'
|
||
: '已停用'
|
||
}}
|
||
</Tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'action'">
|
||
<Button
|
||
size="small"
|
||
type="link"
|
||
@click="editEndpointRecord(record)"
|
||
>
|
||
编辑
|
||
</Button>
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
</Tabs.TabPane>
|
||
|
||
<Tabs.TabPane key="consumers">
|
||
<template #tab>
|
||
<span class="tab-label"
|
||
><IconifyIcon icon="lucide:users-round" />调用方</span
|
||
>
|
||
</template>
|
||
<div class="table-toolbar">
|
||
<div>
|
||
<h3>调用方识别</h3>
|
||
<p>来源于 Kong Consumer;匿名请求按来源 IP 聚合。</p>
|
||
</div>
|
||
<Tag color="cyan">{{ consumers.length }} 个已识别 Consumer</Tag>
|
||
</div>
|
||
<Table
|
||
:columns="consumerColumns"
|
||
:data-source="consumers"
|
||
:loading="loading"
|
||
:pagination="{ pageSize: 10 }"
|
||
:scroll="{ x: 1250 }"
|
||
row-key="id"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'identity'">
|
||
<div class="asset-name">
|
||
<div class="asset-icon consumer-icon">
|
||
<IconifyIcon icon="lucide:building-2" />
|
||
</div>
|
||
<div>
|
||
<strong>{{
|
||
record.displayName || record.username || '未命名调用方'
|
||
}}</strong>
|
||
<span>{{ record.username }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'lastSeenAt'">
|
||
<span class="muted-text">{{
|
||
formatTime(record.lastSeenAt)
|
||
}}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'status'">
|
||
<Tag
|
||
:color="record.status === 'active' ? 'success' : 'default'"
|
||
>
|
||
{{ record.status === 'active' ? '活跃' : '停用' }}
|
||
</Tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'action'">
|
||
<Button
|
||
size="small"
|
||
type="link"
|
||
@click="editConsumerRecord(record)"
|
||
>
|
||
编辑
|
||
</Button>
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
</Tabs.TabPane>
|
||
|
||
<Tabs.TabPane key="usage">
|
||
<template #tab>
|
||
<span class="tab-label"
|
||
><IconifyIcon icon="lucide:table-properties" />使用统计</span
|
||
>
|
||
</template>
|
||
<div class="table-toolbar usage-toolbar">
|
||
<div>
|
||
<h3>小时聚合数据</h3>
|
||
<p>仅保留调用方、来源 IP、时间与次数,不保存请求内容。</p>
|
||
</div>
|
||
<Select
|
||
v-model:value="selectedEndpointId"
|
||
allow-clear
|
||
class="endpoint-filter"
|
||
:options="endpointOptions"
|
||
placeholder="筛选接口"
|
||
show-search
|
||
@change="refreshAll"
|
||
/>
|
||
</div>
|
||
<Table
|
||
:columns="usageColumns"
|
||
:data-source="usage"
|
||
:loading="loading"
|
||
:pagination="{ pageSize: 15, showSizeChanger: true }"
|
||
:scroll="{ x: 1250 }"
|
||
:row-key="usageRowKey"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'bucket'">
|
||
<span class="muted-text">{{ formatTime(record.bucket) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'endpoint'">
|
||
<div class="usage-endpoint">
|
||
<strong>{{ record.endpointName }}</strong>
|
||
<span
|
||
>{{ serviceName(record.serviceCode) }} ·
|
||
{{ record.category }}</span
|
||
>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'caller'">
|
||
<div class="caller-cell">
|
||
<Tag
|
||
:color="
|
||
record.callerType === 'consumer' ? 'blue' : 'orange'
|
||
"
|
||
>
|
||
{{ record.callerType === 'consumer' ? 'Consumer' : '匿名' }}
|
||
</Tag>
|
||
<span>{{ record.callerName }}</span>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
</Tabs.TabPane>
|
||
</Tabs>
|
||
</Card>
|
||
|
||
<Modal
|
||
v-model:open="endpointModalOpen"
|
||
:confirm-loading="endpointSaving"
|
||
:title="editingEndpointId ? '编辑接口资产' : '新增接口资产'"
|
||
width="720px"
|
||
@ok="saveEndpoint"
|
||
>
|
||
<Form class="modal-form" layout="vertical">
|
||
<div class="form-grid">
|
||
<FormItem label="接口名称" required>
|
||
<Input
|
||
v-model:value="endpointForm.name"
|
||
placeholder="例如:蓝字发票详情"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="业务分类" required>
|
||
<Input
|
||
v-model:value="endpointForm.category"
|
||
placeholder="例如:票通开票"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="所属服务" required>
|
||
<Select
|
||
v-model:value="endpointForm.serviceCode"
|
||
:options="serviceOptions"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="HTTP Method">
|
||
<Select
|
||
v-model:value="endpointForm.httpMethod"
|
||
:options="
|
||
['*', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((value) => ({
|
||
label: value,
|
||
value,
|
||
}))
|
||
"
|
||
/>
|
||
</FormItem>
|
||
</div>
|
||
<FormItem label="路径模板" required>
|
||
<Input
|
||
v-model:value="endpointForm.pathTemplate"
|
||
placeholder="/api/open/v1/blue-invoices/{invoiceCode}"
|
||
/>
|
||
</FormItem>
|
||
<div class="form-grid">
|
||
<FormItem label="Kong Service ID">
|
||
<Input
|
||
v-model:value="endpointForm.kongServiceId"
|
||
placeholder="可选"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="Kong Route ID">
|
||
<Input
|
||
v-model:value="endpointForm.kongRouteId"
|
||
placeholder="可选"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="负责人">
|
||
<Input
|
||
v-model:value="endpointForm.owner"
|
||
placeholder="部门或负责人"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="接口状态">
|
||
<Select
|
||
v-model:value="endpointForm.status"
|
||
:options="[
|
||
{ label: '启用', value: 'enabled' },
|
||
{ label: '停用', value: 'disabled' },
|
||
{ label: '维护中', value: 'maintenance' },
|
||
]"
|
||
/>
|
||
</FormItem>
|
||
</div>
|
||
<FormItem label="接口说明">
|
||
<Input.TextArea v-model:value="endpointForm.description" :rows="3" />
|
||
</FormItem>
|
||
<div class="monitor-switch">
|
||
<div>
|
||
<strong>纳入调用统计</strong>
|
||
<span>关闭后,Kong 推送的该接口事件将不再累计。</span>
|
||
</div>
|
||
<Switch v-model:checked="endpointForm.monitorEnabled" />
|
||
</div>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
v-model:open="consumerModalOpen"
|
||
:confirm-loading="consumerSaving"
|
||
title="编辑调用方资料"
|
||
width="560px"
|
||
@ok="saveConsumer"
|
||
>
|
||
<Form class="modal-form" layout="vertical">
|
||
<FormItem label="展示名称">
|
||
<Input
|
||
v-model:value="consumerForm.displayName"
|
||
placeholder="客户、系统或项目名称"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="状态">
|
||
<Select
|
||
v-model:value="consumerForm.status"
|
||
:options="[
|
||
{ label: '活跃', value: 'active' },
|
||
{ label: '停用', value: 'inactive' },
|
||
]"
|
||
/>
|
||
</FormItem>
|
||
<FormItem label="备注">
|
||
<Input.TextArea v-model:value="consumerForm.description" :rows="4" />
|
||
</FormItem>
|
||
<div v-if="editingConsumer" class="consumer-reference">
|
||
<span>Kong Consumer</span>
|
||
<code
|
||
>{{ editingConsumer.username }} ·
|
||
{{ editingConsumer.kongConsumerId }}</code
|
||
>
|
||
</div>
|
||
</Form>
|
||
</Modal>
|
||
</Page>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.open-api-page {
|
||
--api-blue: #2563eb;
|
||
--api-ink: #0f172a;
|
||
--api-muted: #64748b;
|
||
--api-line: rgba(148, 163, 184, 0.2);
|
||
}
|
||
|
||
.hero-panel {
|
||
position: relative;
|
||
min-height: 190px;
|
||
overflow: hidden;
|
||
padding: 34px 38px;
|
||
color: #fff;
|
||
background:
|
||
linear-gradient(120deg, rgba(15, 23, 42, 0.98), rgba(18, 52, 102, 0.96)),
|
||
#0f172a;
|
||
border: 1px solid rgba(96, 165, 250, 0.2);
|
||
border-radius: 18px;
|
||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.18);
|
||
}
|
||
|
||
.hero-content {
|
||
position: relative;
|
||
z-index: 2;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
min-height: 120px;
|
||
gap: 24px;
|
||
}
|
||
|
||
.hero-content h1 {
|
||
margin: 10px 0 8px;
|
||
font-size: clamp(28px, 3vw, 40px);
|
||
font-weight: 680;
|
||
line-height: 1.2;
|
||
letter-spacing: -1px;
|
||
}
|
||
|
||
.hero-content p {
|
||
max-width: 640px;
|
||
margin: 0;
|
||
color: rgba(226, 232, 240, 0.78);
|
||
font-size: 15px;
|
||
}
|
||
|
||
.hero-kicker,
|
||
.panel-kicker {
|
||
color: #7dd3fc;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.18em;
|
||
}
|
||
|
||
.live-dot {
|
||
display: inline-block;
|
||
width: 7px;
|
||
height: 7px;
|
||
margin-right: 8px;
|
||
background: #34d399;
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 0 6px rgba(52, 211, 153, 0.12);
|
||
}
|
||
|
||
.hero-actions,
|
||
.health-pill {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.hero-actions {
|
||
gap: 14px;
|
||
}
|
||
|
||
.health-pill {
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
color: #bfdbfe;
|
||
background: rgba(255, 255, 255, 0.07);
|
||
border: 1px solid rgba(191, 219, 254, 0.15);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.hero-orb {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(3px);
|
||
}
|
||
|
||
.hero-orb-one {
|
||
top: -110px;
|
||
right: 16%;
|
||
width: 260px;
|
||
height: 260px;
|
||
background: rgba(37, 99, 235, 0.18);
|
||
}
|
||
|
||
.hero-orb-two {
|
||
right: -60px;
|
||
bottom: -150px;
|
||
width: 300px;
|
||
height: 300px;
|
||
background: rgba(14, 165, 233, 0.12);
|
||
}
|
||
|
||
.filter-card {
|
||
margin-top: 16px;
|
||
border-radius: 14px;
|
||
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.05);
|
||
}
|
||
|
||
.filter-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.filter-title {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-right: 6px;
|
||
gap: 8px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.range-picker {
|
||
min-width: 330px;
|
||
}
|
||
|
||
.filter-select {
|
||
width: 150px;
|
||
}
|
||
|
||
.metric-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.metric-card {
|
||
position: relative;
|
||
overflow: hidden;
|
||
border-radius: 14px;
|
||
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.045);
|
||
}
|
||
|
||
.metric-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.metric-icon {
|
||
display: grid;
|
||
width: 36px;
|
||
height: 36px;
|
||
font-size: 18px;
|
||
border-radius: 10px;
|
||
place-items: center;
|
||
}
|
||
|
||
.metric-label {
|
||
color: var(--api-muted);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.metric-value {
|
||
margin-top: 18px;
|
||
color: var(--api-ink);
|
||
font-size: 28px;
|
||
font-weight: 720;
|
||
line-height: 1;
|
||
letter-spacing: -0.7px;
|
||
}
|
||
|
||
.metric-value small {
|
||
margin-left: 2px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.metric-note {
|
||
margin-top: 9px;
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.metric-line {
|
||
position: absolute;
|
||
right: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
height: 2px;
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.content-card {
|
||
margin-top: 16px;
|
||
border-radius: 16px;
|
||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.055);
|
||
}
|
||
|
||
.tab-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
}
|
||
|
||
.overview-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.65fr) minmax(300px, 0.75fr);
|
||
gap: 16px;
|
||
}
|
||
|
||
.chart-panel {
|
||
min-width: 0;
|
||
padding: 20px;
|
||
background: rgba(248, 250, 252, 0.48);
|
||
border: 1px solid var(--api-line);
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.panel-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.panel-heading h3,
|
||
.table-toolbar h3 {
|
||
margin: 4px 0 0;
|
||
color: var(--api-ink);
|
||
font-size: 17px;
|
||
font-weight: 660;
|
||
}
|
||
|
||
.panel-kicker {
|
||
color: #3b82f6;
|
||
font-size: 9px;
|
||
}
|
||
|
||
.trend-chart {
|
||
height: 330px;
|
||
}
|
||
|
||
.side-chart {
|
||
height: 285px;
|
||
}
|
||
|
||
.caller-ranking {
|
||
min-height: 285px;
|
||
padding-top: 12px;
|
||
}
|
||
|
||
.caller-row {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 9px 3px;
|
||
gap: 10px;
|
||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||
}
|
||
|
||
.rank-index {
|
||
width: 22px;
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-align: center;
|
||
}
|
||
|
||
.rank-index.podium {
|
||
color: #2563eb;
|
||
}
|
||
|
||
.caller-avatar {
|
||
display: grid;
|
||
width: 34px;
|
||
height: 34px;
|
||
color: #2563eb;
|
||
background: rgba(37, 99, 235, 0.08);
|
||
border-radius: 10px;
|
||
place-items: center;
|
||
}
|
||
|
||
.caller-copy {
|
||
display: flex;
|
||
flex: 1;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.caller-copy strong,
|
||
.asset-name strong,
|
||
.usage-endpoint strong {
|
||
overflow: hidden;
|
||
color: var(--api-ink);
|
||
font-size: 13px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.caller-copy span,
|
||
.asset-name span,
|
||
.usage-endpoint span {
|
||
color: #94a3b8;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.caller-row b {
|
||
color: #334155;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
height: 250px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #94a3b8;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.empty-state svg {
|
||
font-size: 28px;
|
||
}
|
||
|
||
.table-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 4px 2px 20px;
|
||
gap: 18px;
|
||
}
|
||
|
||
.table-toolbar p {
|
||
margin: 5px 0 0;
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.asset-name,
|
||
.path-cell,
|
||
.caller-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.asset-name {
|
||
gap: 11px;
|
||
}
|
||
|
||
.asset-name > div:last-child,
|
||
.usage-endpoint {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.asset-icon {
|
||
display: grid;
|
||
width: 36px;
|
||
height: 36px;
|
||
flex: 0 0 auto;
|
||
color: #2563eb;
|
||
background: rgba(37, 99, 235, 0.08);
|
||
border-radius: 10px;
|
||
place-items: center;
|
||
}
|
||
|
||
.consumer-icon {
|
||
color: #7c3aed;
|
||
background: rgba(124, 58, 237, 0.08);
|
||
}
|
||
|
||
.path-cell {
|
||
min-width: 0;
|
||
gap: 8px;
|
||
}
|
||
|
||
.path-cell code,
|
||
.consumer-reference code {
|
||
overflow: hidden;
|
||
color: #475569;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 12px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.muted-text {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.caller-cell {
|
||
gap: 8px;
|
||
}
|
||
|
||
.endpoint-filter {
|
||
width: 320px;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
column-gap: 16px;
|
||
}
|
||
|
||
.modal-form {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.monitor-switch,
|
||
.consumer-reference {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 15px 16px;
|
||
background: rgba(241, 245, 249, 0.7);
|
||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.monitor-switch > div {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.monitor-switch strong {
|
||
color: #1e293b;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.monitor-switch span,
|
||
.consumer-reference span {
|
||
color: #94a3b8;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.consumer-reference {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.spinning {
|
||
animation: rotate 0.9s linear infinite;
|
||
}
|
||
|
||
@keyframes rotate {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1280px) {
|
||
.metric-grid {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.hero-content {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.overview-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.metric-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.range-picker {
|
||
min-width: 100%;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.hero-panel {
|
||
padding: 26px 22px;
|
||
}
|
||
|
||
.hero-actions,
|
||
.table-toolbar {
|
||
width: 100%;
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.metric-grid,
|
||
.form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.filter-select,
|
||
.endpoint-filter {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
:deep(.ant-card-body) {
|
||
padding: 20px;
|
||
}
|
||
|
||
:deep(.center-tabs > .ant-tabs-nav) {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
:deep(.center-tabs > .ant-tabs-nav::before) {
|
||
border-color: rgba(148, 163, 184, 0.18);
|
||
}
|
||
|
||
:deep(.ant-table-wrapper .ant-table) {
|
||
border-radius: 12px;
|
||
}
|
||
|
||
:global(.dark) .open-api-page {
|
||
--api-ink: #e2e8f0;
|
||
--api-muted: #94a3b8;
|
||
--api-line: rgba(148, 163, 184, 0.14);
|
||
}
|
||
|
||
:global(.dark) .open-api-page .chart-panel {
|
||
background: rgba(15, 23, 42, 0.38);
|
||
}
|
||
|
||
:global(.dark) .open-api-page .caller-row b,
|
||
:global(.dark) .open-api-page .path-cell code {
|
||
color: #cbd5e1;
|
||
}
|
||
|
||
:global(.dark) .open-api-page .monitor-switch,
|
||
:global(.dark) .open-api-page .consumer-reference {
|
||
background: rgba(15, 23, 42, 0.45);
|
||
}
|
||
|
||
:global(.dark) .open-api-page .monitor-switch strong {
|
||
color: #e2e8f0;
|
||
}
|
||
</style>
|