Files
AILab/vue2/apps/web-antd/src/views/manager/open-api/index.vue
T

1582 lines
40 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 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>