feat: 完成外部系统模块多维度优化迭代

本次提交包含以下核心改进:
1.  统一组件命名规范:为所有列表页组件添加defineOptions配置组件名
2.  重构侧边导航系统:支持折叠态、路由name跳转、自动激活匹配
3.  新增通用基础组件:ErrorRetryAlert错误重试提示、PermissionButton权限按钮
4.  优化列表页错误处理:替换全局错误提示为统一ErrorRetryAlert组件
5.  增强仪表盘能力:添加自动重试机制、traceId展示、多维度指标统计
6.  完善页面容器功能:支持详情页标签、面包屑优化、权限控制
7.  新增系统健康状态监控:集成健康检查API,展示系统健康状态
8.  优化页面交互:加载态、空态、错误态统一视觉语言
This commit is contained in:
Kris 2026-07-11 05:44:45 +08:00
parent 70a45c2acc
commit d38f325577
32 changed files with 932 additions and 214 deletions

View File

@ -1,6 +1,6 @@
<template>
<div class="asset-detail-view">
<PageContainer>
<PageContainer :detail-label="detail?.name || ''">
<template #header>
<div class="asset-detail-view__header">
<div class="asset-detail-view__title-row">

View File

@ -102,16 +102,13 @@
<div class="asset-list-view__content">
<LoadingState v-if="listLoading && !list.length" type="skeleton" :rows="6" />
<EmptyState
<ErrorRetryAlert
v-else-if="listError"
type="default"
title="加载失败"
:description="listError.message || '资产列表加载失败'"
>
<template #actions>
<a-button type="primary" @click="loadInitial">重试</a-button>
</template>
</EmptyState>
:error="listError"
message="加载失败"
:description="listError?.message || ''"
@retry="loadInitial"
/>
<EmptyState v-else-if="!list.length" type="default" title="暂无资产" />
@ -203,11 +200,12 @@
:width="640"
>
<LoadingState v-if="referencesLoading" type="mask" text="加载引用关系..." />
<EmptyState
<ErrorRetryAlert
v-else-if="referencesError"
type="default"
title="加载失败"
:description="referencesError.message"
:error="referencesError"
message="加载失败"
:description="referencesError?.message || ''"
retry-text=""
/>
<template v-else-if="references">
<section class="asset-list-view__ref-section">
@ -252,11 +250,12 @@
:width="720"
>
<LoadingState v-if="previewLoading" type="mask" text="加载预览..." />
<EmptyState
<ErrorRetryAlert
v-else-if="previewError"
type="default"
title="加载失败"
:description="previewError.message"
:error="previewError"
message="加载失败"
:description="previewError?.message || ''"
retry-text=""
/>
<template v-else-if="preview">
<a-descriptions :column="1" size="small" bordered>
@ -337,6 +336,8 @@
</template>
<script setup>
defineOptions({ name: 'EsAssets' })
import { ref, computed, watch, onMounted } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { message } from 'ant-design-vue'
@ -354,6 +355,7 @@ import FilterBar from '@/components/external-systems/common/FilterBar.vue'
import PaginationBar from '@/components/external-systems/common/PaginationBar.vue'
import StatusTag from '@/components/external-systems/common/StatusTag.vue'
import EmptyState from '@/components/external-systems/common/EmptyState.vue'
import ErrorRetryAlert from '@/components/external-systems/common/ErrorRetryAlert.vue'
import LoadingState from '@/components/external-systems/common/LoadingState.vue'
import CodeBlock from '@/components/external-systems/common/CodeBlock.vue'
import ConfirmDialog from '@/components/external-systems/common/ConfirmDialog.vue'
@ -754,22 +756,10 @@ function handleEditSuccess() {
}
// ===== =====
watch(listError, (err) => {
if (err) message.error(err.message || '资产列表加载失败')
})
watch(statsError, (err) => {
if (err) message.error(err.message || '资产统计加载失败')
})
watch(previewError, (err) => {
if (err) message.error(err.message || '资产预览加载失败')
})
watch(referencesError, (err) => {
if (err) message.error(err.message || '资产引用加载失败')
})
// ===== =====
onMounted(loadInitial)
</script>

View File

@ -87,6 +87,13 @@
<!-- 表格 -->
<div class="env-list-view__table">
<LoadingState v-if="listLoading && !list.length" type="skeleton" :rows="6" />
<ErrorRetryAlert
v-else-if="listError"
:error="listError"
message="加载失败"
:description="listError?.message || ''"
@retry="fetchList"
/>
<EmptyState
v-else-if="isEmpty && !listLoading"
type="filter"
@ -217,6 +224,8 @@
</template>
<script setup>
defineOptions({ name: 'EsEnvironments' })
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
@ -232,6 +241,7 @@ import FilterBar from '@/components/external-systems/common/FilterBar.vue'
import PaginationBar from '@/components/external-systems/common/PaginationBar.vue'
import StatusTag from '@/components/external-systems/common/StatusTag.vue'
import EmptyState from '@/components/external-systems/common/EmptyState.vue'
import ErrorRetryAlert from '@/components/external-systems/common/ErrorRetryAlert.vue'
import LoadingState from '@/components/external-systems/common/LoadingState.vue'
import EnvironmentDetailDrawer from './EnvironmentDetailDrawer.vue'
import EnvironmentDiffDrawer from './EnvironmentDiffDrawer.vue'
@ -543,10 +553,6 @@ function handleBatchSuccess() {
}
// ===== =====
watch(listError, (err) => {
if (err) message.error(err?.message || '加载环境列表失败')
})
watch(submitError, (err) => {
if (err) message.error(err?.message || '操作失败')
})

View File

@ -7,7 +7,15 @@
@close="handleClose"
>
<LoadingState v-if="governance.loading" type="skeleton" :rows="8" />
<a-tabs v-else v-model:activeKey="activeTab">
<a-alert
v-else-if="!isAdmin"
type="info"
show-icon
message="您暂无修改权限,以下配置为只读模式"
description="如需修改运行时治理配置,请联系管理员申请 [ADMIN] 权限。"
style="margin-bottom: 16px"
/>
<a-tabs v-if="!governance.loading" v-model:activeKey="activeTab">
<!-- 限流 -->
<a-tab-pane key="rateLimit" tab="限流">
<a-form ref="rateLimitFormRef" :model="formData.rateLimit" layout="vertical">

View File

@ -1,6 +1,6 @@
<template>
<div class="es-system-detail">
<PageContainer :title="detail?.name || '系统详情'" :description="detail?.slug || ''">
<PageContainer :title="detail?.name || '系统详情'" :description="detail?.slug || ''" :detail-label="detail?.name || ''">
<template #actions>
<a-space>
<a-button @click="handleBack">

View File

@ -47,7 +47,10 @@
<Server :size="18" />
<span class="es-system-list__stat-title">系统总数</span>
</div>
<div class="es-system-list__stat-value">{{ stats?.total ?? 0 }}</div>
<div class="es-system-list__stat-value">
<a-spin v-if="statsLoading" :indicator="null" size="small" />
<template v-else>{{ stats?.total ?? 0 }}</template>
</div>
</div>
<div class="es-system-list__stat-card">
<div class="es-system-list__stat-header">
@ -55,7 +58,8 @@
<span class="es-system-list__stat-title">已启用</span>
</div>
<div class="es-system-list__stat-value es-system-list__stat-value--success">
{{ stats?.by_enabled?.['true'] ?? 0 }}
<a-spin v-if="statsLoading" :indicator="null" size="small" />
<template v-else>{{ stats?.by_enabled?.['true'] ?? 0 }}</template>
</div>
</div>
<div class="es-system-list__stat-card">
@ -64,7 +68,8 @@
<span class="es-system-list__stat-title">熔断 OPEN</span>
</div>
<div class="es-system-list__stat-value es-system-list__stat-value--error">
{{ circuitBreakersStatus?.open ?? 0 }}
<a-spin v-if="circuitBreakersLoading" :indicator="null" size="small" />
<template v-else>{{ circuitBreakersStatus?.open ?? 0 }}</template>
</div>
</div>
<div class="es-system-list__stat-card">
@ -73,10 +78,18 @@
<span class="es-system-list__stat-title">适配器类型数</span>
</div>
<div class="es-system-list__stat-value">
{{ Object.keys(stats?.by_adapter_type || {}).length }}
<a-spin v-if="statsLoading" :indicator="null" size="small" />
<template v-else>{{ Object.keys(stats?.by_adapter_type || {}).length }}</template>
</div>
</div>
</div>
<div v-if="statsError || circuitBreakersError || formattedLastRefreshed" class="es-system-list__stats-meta">
<span v-if="statsError" class="es-system-list__stats-meta-error">统计加载失败</span>
<span v-if="circuitBreakersError" class="es-system-list__stats-meta-error">熔断器状态加载失败</span>
<span v-if="formattedLastRefreshed" class="es-system-list__stats-meta-time">
最后刷新{{ formattedLastRefreshed }}
</span>
</div>
<!-- 筛选区 -->
<FilterBar
@ -90,13 +103,12 @@
<!-- 表格 -->
<LoadingState v-if="listLoading && !list.length" type="skeleton" :rows="8" />
<EmptyState
<ErrorRetryAlert
v-else-if="listError"
type="default"
title="加载失败"
:description="listError.message || '请重试'"
action-text="重试"
@action="handleRefresh"
:error="listError"
message="加载失败"
:description="listError?.message || ''"
@retry="handleRefresh"
/>
<EmptyState v-else-if="!list.length" type="filter" />
<div v-else class="es-system-list__table-wrapper">
@ -249,7 +261,9 @@
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
defineOptions({ name: 'EsSystems' })
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
@ -267,11 +281,13 @@ import {
} from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import { useExternalSystem } from '@/composables/external-systems/useExternalSystem'
import { getAdapterOverviewApi, getAdapterDetailApi } from '@/apis/external-systems/adapter_metadata_api'
import PageContainer from '@/components/external-systems/layout/PageContainer.vue'
import FilterBar from '@/components/external-systems/common/FilterBar.vue'
import PaginationBar from '@/components/external-systems/common/PaginationBar.vue'
import StatusTag from '@/components/external-systems/common/StatusTag.vue'
import EmptyState from '@/components/external-systems/common/EmptyState.vue'
import ErrorRetryAlert from '@/components/external-systems/common/ErrorRetryAlert.vue'
import LoadingState from '@/components/external-systems/common/LoadingState.vue'
import ConfirmDialog from '@/components/external-systems/common/ConfirmDialog.vue'
import SystemFormModal from './SystemFormModal.vue'
@ -293,14 +309,16 @@ const {
filters,
stats,
statsLoading,
statsError,
circuitBreakersStatus,
exporting,
submitting,
togglingEnabled,
batchCloning,
circuitBreakersLoading,
circuitBreakersError,
latestHealth,
lastRefreshedAt,
fetchList,
fetchStats,
fetchCircuitBreakersStatus,
fetchLatestHealth,
setFilter,
resetFilters,
setPagination,
@ -325,20 +343,34 @@ const systemImportVisible = ref(false)
const toolPackageExportVisible = ref(false)
const toolPackageImportVisible = ref(false)
//
const adapterOptions = ref([])
const sourceTypeOptions = ref([])
const adapterLoading = ref(false)
// ===== Computed =====
const isAdmin = computed(() => userStore.isAdmin)
const formattedLastRefreshed = computed(() => {
if (!lastRefreshedAt.value) return ''
const d = new Date(lastRefreshedAt.value)
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`
})
// ===== Constants =====
const healthStatusMap = {
healthy: { label: '健康', color: 'success' },
degraded: { label: '降级', color: 'warning' },
unhealthy: { label: '异常', color: 'error' },
auth_failure: { label: '认证失败', color: 'error' },
connectivity_issue: { label: '连接异常', color: 'error' },
not_checked: { label: '未探测', color: 'default' },
circuit_open: { label: '熔断', color: 'error' },
circuit_half_open: { label: '半开', color: 'warning' },
unknown: { label: '未探测', color: 'default' }
}
const filterFields = [
const filterFields = computed(() => [
{
name: 'category',
label: '分类',
@ -356,14 +388,16 @@ const filterFields = [
label: '适配器',
type: 'select',
placeholder: '请选择适配器',
options: []
loading: adapterLoading.value,
options: adapterOptions.value
},
{
name: 'source_type',
label: '数据源',
type: 'select',
placeholder: '请选择数据源',
options: []
options: sourceTypeOptions.value,
disabled: !filters.adapter_type
},
{
name: 'enabled',
@ -381,7 +415,7 @@ const filterFields = [
type: 'input',
placeholder: '请输入 slug 或名称'
}
]
])
const columns = [
{ title: 'Slug', dataIndex: 'slug', key: 'slug', width: 160, ellipsis: true },
@ -396,19 +430,29 @@ const columns = [
// ===== Methods =====
// circuitBreakersStatus { total, closed, open, half_open, systems: list[dict] }
// systems state closed / open / half_open
const cbMap = computed(() => {
const systems = circuitBreakersStatus.value?.systems || []
return new Map(systems.map((c) => [c.system_id, c]))
})
function getHealthStatus(record) {
const cb = circuitBreakersStatus.value?.systems?.find((c) => c.system_id === record.id)
const cb = cbMap.value.get(record.id)
if (cb?.state === 'open') return 'circuit_open'
if (cb?.state === 'half_open') return 'circuit_half_open'
if (record.health_status) return record.health_status
const health = latestHealth.value[record.id]
if (health?.health_status) return health.health_status
return 'unknown'
}
function getHealthTooltip(record) {
const cb = circuitBreakersStatus.value?.systems?.find((c) => c.system_id === record.id)
const cb = cbMap.value.get(record.id)
const health = latestHealth.value[record.id]
if (cb) {
return `熔断器:${cb.state || '未知'}`
}
if (health?.checked_at) {
return `最近检查:${health.checked_at}`
}
return record.enabled ? '未探测健康状态' : '系统已禁用'
}
@ -423,12 +467,49 @@ async function loadAll() {
fetchStats(),
fetchCircuitBreakersStatus()
])
//
if (list.value?.length) {
const ids = list.value.map((item) => item.id).filter(Boolean)
await fetchLatestHealth(ids)
}
}
async function handleRefresh() {
await loadAll()
}
async function loadAdapterOptions() {
adapterLoading.value = true
try {
const res = await getAdapterOverviewApi()
const items = res?.data?.items || []
adapterOptions.value = items.map((item) => ({
label: item.display_name || item.adapter_type,
value: item.adapter_type
}))
} catch (err) {
adapterOptions.value = []
console.warn('加载适配器元数据失败', err)
} finally {
adapterLoading.value = false
}
}
async function loadSourceTypeOptions(adapterType) {
if (!adapterType) {
sourceTypeOptions.value = []
return
}
try {
const res = await getAdapterDetailApi(adapterType)
const types = res?.data?.supported_source_types || []
sourceTypeOptions.value = types.map((t) => ({ label: t, value: t }))
} catch (err) {
sourceTypeOptions.value = []
console.warn(`加载 ${adapterType} 数据源类型失败`, err)
}
}
function handleSearch(formData) {
setFilter(formData)
fetchList()
@ -439,13 +520,22 @@ function handleReset() {
fetchList()
}
function handleFieldChange() {
//
function handleFieldChange({ field, value }) {
//
if (field === 'adapter_type') {
if (value !== filters.adapter_type) {
filters.source_type = undefined
}
loadSourceTypeOptions(value)
}
}
function handlePageChange({ page, pageSize }) {
async function handlePageChange({ page, pageSize }) {
setPagination({ page, pageSize })
fetchList()
await fetchList()
if (list.value?.length) {
await fetchLatestHealth(list.value.map((item) => item.id).filter(Boolean))
}
}
function handleDetail(record) {
@ -514,10 +604,14 @@ async function handleDeleteConfirm() {
async function handleToggleEnabled(record, checked) {
try {
await setEnabled(record.id, checked)
const updated = await setEnabled(record.id, checked)
message.success(checked ? '已启用' : '已禁用')
//
record.enabled = checked
// 使
if (updated && typeof updated.enabled === 'boolean') {
Object.assign(record, updated)
} else {
record.enabled = checked
}
await fetchStats()
} catch (err) {
message.error(err?.message || '操作失败')
@ -576,15 +670,9 @@ async function handleExportConfirm() {
}
}
// ===== Watch =====
watch(listError, (err) => {
if (err) message.error(err.message || '系统列表加载失败')
})
watch(statsLoading, () => {}, { deep: true })
// ===== Lifecycle =====
onMounted(() => {
loadAdapterOptions()
loadAll()
})
</script>
@ -657,5 +745,23 @@ onMounted(() => {
font-size: 13px;
}
}
&__stats-meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: -8px;
margin-bottom: 16px;
font-size: 12px;
&-error {
color: var(--color-error-600);
}
&-time {
margin-left: auto;
color: var(--gray-500);
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<PageContainer :title="detail?.name || '工具详情'" :description="detail?.slug || ''">
<PageContainer :title="detail?.name || '工具详情'" :description="detail?.slug || ''" :detail-label="detail?.name || ''">
<template #actions>
<a-space>
<a-button @click="handleExecute">

View File

@ -60,6 +60,13 @@
<!-- 数据表格 -->
<div class="tool-list__table">
<LoadingState v-if="listLoading && !list.length" type="skeleton" :loading="true" :rows="6" />
<ErrorRetryAlert
v-else-if="listError"
:error="listError"
message="加载失败"
:description="listError?.message || ''"
@retry="fetchList"
/>
<EmptyState
v-else-if="!listLoading && list.length === 0"
type="default"
@ -185,6 +192,8 @@
</template>
<script setup>
defineOptions({ name: 'EsTools' })
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
@ -206,6 +215,7 @@ import FilterBar from '@/components/external-systems/common/FilterBar.vue'
import PaginationBar from '@/components/external-systems/common/PaginationBar.vue'
import StatusTag from '@/components/external-systems/common/StatusTag.vue'
import EmptyState from '@/components/external-systems/common/EmptyState.vue'
import ErrorRetryAlert from '@/components/external-systems/common/ErrorRetryAlert.vue'
import LoadingState from '@/components/external-systems/common/LoadingState.vue'
import ConfirmDialog from '@/components/external-systems/common/ConfirmDialog.vue'
import BatchResultModal from '@/components/external-systems/common/BatchResultModal.vue'
@ -479,10 +489,6 @@ const handleModalSuccess = () => {
}
// ===== =====
watch(listError, (err) => {
if (err) message.error(err?.message || '加载工具列表失败')
})
watch(submitError, (err) => {
if (err) message.error(err?.message || '操作失败')
})

View File

@ -0,0 +1,48 @@
<template>
<a-alert v-if="error" type="error" show-icon :message="defaultMessage" :description="description">
<template v-if="$slots.action || retryText" #action>
<slot name="action">
<a-button size="small" @click="$emit('retry')">{{ retryText }}</a-button>
</slot>
</template>
</a-alert>
</template>
<script setup>
import { computed } from 'vue'
/**
* 错误提示 + 重试按钮
*
* 用于列表/详情/统计等数据加载失败时统一展示错误信息与重试入口
* error null/undefined/空字符串时不渲染v-if 由内部控制
*
* 设计说明对齐 channels 模块的 ErrorRetryAlert
* - error 可以是 Error 对象{ message } 对象或字符串由调用方决定 message/description 的来源
* - message prop 缺省时自动从 error 提取string 直接用对象取 .message
* - retryText 为空字符串时不显示默认重试按钮适用于不需要重试的场景
* - 自定义 action 通过 #action slot 传入
*
* EmptyState 的职责区分
* - EmptyState 仅用于无数据场景语义为空态
* - ErrorRetryAlert 用于加载失败场景语义为错误态避免语义误用
*/
const props = defineProps({
// null/undefined/
error: { type: [Object, Error, String], default: null },
// error message
message: { type: String, default: '' },
// message
description: { type: String, default: '' },
// #action slot
retryText: { type: String, default: '重试' }
})
defineEmits(['retry'])
const defaultMessage = computed(() => {
if (props.message) return props.message
if (!props.error) return ''
if (typeof props.error === 'string') return props.error
return props.error.message || ''
})
</script>

View File

@ -11,6 +11,7 @@
:placeholder="field.placeholder || `请选择${field.label}`"
:options="field.options"
:loading="field.loading"
:disabled="field.disabled"
:allow-clear="field.allowClear !== false"
style="min-width: 160px"
@change="handleFieldChange(field)"

View File

@ -0,0 +1,58 @@
<template>
<a-tooltip v-if="!allowed" :title="deniedTooltip" :placement="placement">
<span>
<a-button :disabled="true" :type="type" :size="size" :danger="danger">
<template v-if="$slots.icon" #icon><slot name="icon" /></template>
<slot />
</a-button>
</span>
</a-tooltip>
<a-button
v-else
:type="type"
:size="size"
:danger="danger"
:loading="loading"
:disabled="disabled"
@click="$emit('click', $event)"
>
<template v-if="$slots.icon" #icon><slot name="icon" /></template>
<slot />
</a-button>
</template>
<script setup>
/**
* 权限控制按钮
*
* 用于需要特定权限 admin才能操作的场景
* - allowed=true正常 a-button
* - allowed=falsea-tooltip 包裹 disabled a-buttonhover 显示权限提示
*
* 透传 a-button type/size/danger/loading/disabled click 事件
* 支持 #icon 与默认 slot按钮文字
*
* 设计说明对齐 channels 模块的 PermissionButton
* - 默认 denied-tooltip 文案统一为 [ADMIN]避免散落的不一致写法
* - 适用于需要禁用 + 提示的场景对于写操作完全隐藏的场景仍用 v-if="isAdmin"
*/
defineProps({
// true=false=tooltip disabled
allowed: { type: Boolean, required: true },
//
deniedTooltip: {
type: String,
default: '您暂无此操作权限,请联系管理员申请 [ADMIN]'
},
// tooltip
placement: { type: String, default: 'top' },
// a-button
type: { type: String, default: 'default' },
size: { type: String, default: 'middle' },
danger: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
defineEmits(['click'])
</script>

View File

@ -269,6 +269,8 @@
</template>
<script setup>
defineOptions({ name: 'EsAccessRules' })
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { message, Modal } from 'ant-design-vue'

View File

@ -214,6 +214,8 @@
</template>
<script setup>
defineOptions({ name: 'EsAlerts' })
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'

View File

@ -261,6 +261,8 @@
</template>
<script setup>
defineOptions({ name: 'EsKeyRotations' })
import { ref, computed, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { Plus, Copy } from 'lucide-vue-next'

View File

@ -322,6 +322,8 @@
</template>
<script setup>
defineOptions({ name: 'EsQuotas' })
import { ref, computed, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {

View File

@ -285,6 +285,8 @@
</template>
<script setup>
defineOptions({ name: 'EsTestCases' })
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'

View File

@ -238,6 +238,8 @@
</template>
<script setup>
defineOptions({ name: 'EsNotificationChannels' })
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { Plus, Edit, Trash2 } from 'lucide-vue-next'

View File

@ -1,6 +1,6 @@
<template>
<div class="vendor-detail-view">
<PageContainer>
<PageContainer :detail-label="detail?.display_name || vendorKey || ''">
<template #header>
<div class="vendor-detail-view__header">
<div class="vendor-detail-view__title-row">

View File

@ -70,16 +70,13 @@
<div class="vendor-list-view__content">
<LoadingState v-if="listLoading && !list.length" type="skeleton" :rows="6" />
<EmptyState
<ErrorRetryAlert
v-else-if="listError"
type="default"
title="加载失败"
:description="listError.message"
>
<template #actions>
<a-button type="primary" @click="loadList">重试</a-button>
</template>
</EmptyState>
:error="listError"
message="加载失败"
:description="listError?.message || ''"
@retry="loadList"
/>
<EmptyState v-else-if="!list.length" type="default" />
@ -139,7 +136,9 @@
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
defineOptions({ name: 'EsVendorIntegrations' })
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
@ -152,6 +151,7 @@ import {
import PageContainer from '@/components/external-systems/layout/PageContainer.vue'
import PaginationBar from '@/components/external-systems/common/PaginationBar.vue'
import EmptyState from '@/components/external-systems/common/EmptyState.vue'
import ErrorRetryAlert from '@/components/external-systems/common/ErrorRetryAlert.vue'
import LoadingState from '@/components/external-systems/common/LoadingState.vue'
import { useVendorIntegration } from '@/composables/external-systems/useVendorIntegration'
@ -298,11 +298,6 @@ function handleViewDetail(item) {
})
}
// ===== =====
watch(listError, (err) => {
if (err) message.error(err.message || '操作失败')
})
// ===== =====
onMounted(loadInitial)
</script>

View File

@ -331,6 +331,8 @@
</template>
<script setup>
defineOptions({ name: 'EsWebhookEvents' })
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { AlertCircle, RotateCcw, RefreshCw } from 'lucide-vue-next'

View File

@ -795,6 +795,8 @@
</template>
<script setup>
defineOptions({ name: 'EsWebhookSubscriptions' })
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {

View File

@ -7,7 +7,7 @@
</div>
<!-- 错误态 -->
<div v-else-if="error" class="es-stat-card__error">
<div v-else-if="error" class="es-stat-card__error" role="alert" aria-live="polite">
<component
:is="errorIcon"
:size="32"
@ -15,6 +15,12 @@
class="es-stat-card__error-icon"
/>
<p class="es-stat-card__error-msg">{{ error }}</p>
<div v-if="traceId" class="es-stat-card__trace-id">
<span class="es-stat-card__trace-id-label">Trace ID:</span>
<a-tooltip :title="copied ? '已复制' : '点击复制'">
<span class="es-stat-card__trace-id-value" @click="copyTraceId">{{ traceId }}</span>
</a-tooltip>
</div>
<a-button size="small" @click="$emit('retry')">重试</a-button>
</div>
@ -71,6 +77,7 @@
<script setup>
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { RouterLink } from 'vue-router'
import { message } from 'ant-design-vue'
import { AlertTriangle, Clock, CloudOff } from 'lucide-vue-next'
import EmptyState from '@/components/external-systems/common/EmptyState.vue'
@ -84,6 +91,8 @@ const props = defineProps({
errorType: { type: String, default: '' },
// HTTP 403/404/500
statusCode: { type: Number, default: null },
// trace_id便
traceId: { type: String, default: '' },
empty: { type: Boolean, default: false },
//
emptyType: { type: String, default: 'dashboard' },

View File

@ -1,83 +1,171 @@
<template>
<nav class="es-sidebar">
<nav ref="sidebarNav" class="es-sidebar" :class="{ 'es-sidebar--collapsed': collapsed }" aria-label="外部系统集成导航">
<!-- 折叠按钮 -->
<button
type="button"
class="es-sidebar__collapse-btn"
:aria-label="collapsed ? '展开侧边栏' : '折叠侧边栏'"
@click="toggleCollapsed"
>
<PanelLeftClose v-if="!collapsed" :size="16" :stroke-width="1.75" />
<PanelLeftOpen v-else :size="16" :stroke-width="1.75" />
</button>
<!-- 总览入口 -->
<RouterLink
to="/external-systems"
class="es-sidebar__overview"
:class="{ 'es-sidebar__overview--active': isOverview }"
:aria-current="isOverview ? 'page' : undefined"
>
<LayoutDashboard :size="16" :stroke-width="1.75" />
<span>总览</span>
<span v-if="!collapsed" class="es-sidebar__overview-label">总览</span>
</RouterLink>
<!-- 业务域分组二级菜单 -->
<div v-for="group in ES_NAVIGATION" :key="group.key" class="es-sidebar__group">
<button
type="button"
class="es-sidebar__group-header"
:class="{ 'es-sidebar__group-header--active': activeGroupKey === group.key }"
@click="handleGroupClick(group.key)"
>
<component :is="group.icon" :size="16" :stroke-width="1.75" />
<span class="es-sidebar__group-label">{{ group.label }}</span>
<ChevronRight
:size="14"
class="es-sidebar__chevron"
:class="{ 'es-sidebar__chevron--expanded': store.expandedGroups[group.key] }"
/>
</button>
<!-- 功能页面三级菜单 -->
<div v-show="store.expandedGroups[group.key]" class="es-sidebar__modules">
<RouterLink
v-for="module in group.modules"
:key="module.path"
:to="module.path"
class="es-sidebar__module"
:class="{ 'es-sidebar__module--active': isModuleActive(module.path) }"
<!-- 展开态分组头 + 模块列表 -->
<template v-if="!collapsed">
<button
type="button"
class="es-sidebar__group-header"
:class="{ 'es-sidebar__group-header--active': activeGroupKey === group.key }"
:aria-expanded="!!store.expandedGroups[group.key]"
:aria-controls="`es-group-${group.key}`"
@click="handleGroupClick(group.key)"
>
{{ module.label }}
</RouterLink>
</div>
<component :is="group.icon" :size="16" :stroke-width="1.75" />
<span class="es-sidebar__group-label">{{ group.label }}</span>
<ChevronRight
:size="14"
class="es-sidebar__chevron"
:class="{ 'es-sidebar__chevron--expanded': store.expandedGroups[group.key] }"
/>
</button>
<!-- 功能页面三级菜单 -->
<div
v-show="store.expandedGroups[group.key]"
:id="`es-group-${group.key}`"
class="es-sidebar__modules"
>
<RouterLink
v-for="module in group.modules"
:key="module.routeName"
:to="{ name: module.routeName }"
class="es-sidebar__module"
:class="{ 'es-sidebar__module--active': isModuleActive(module.routeName) }"
:aria-current="isModuleActive(module.routeName) ? 'page' : undefined"
>
<component :is="module.icon" :size="14" :stroke-width="1.75" class="es-sidebar__module-icon" />
<span>{{ module.label }}</span>
</RouterLink>
</div>
</template>
<!-- 折叠态分组图标按钮 + popover 预览 -->
<a-popover
v-else
placement="rightTop"
trigger="hover"
:overlay-class-name="'es-sidebar__popover'"
>
<template #content>
<div class="es-sidebar__popover-modules">
<div class="es-sidebar__popover-title">{{ group.label }}</div>
<RouterLink
v-for="module in group.modules"
:key="module.routeName"
:to="{ name: module.routeName }"
class="es-sidebar__popover-module"
:class="{ 'es-sidebar__popover-module--active': isModuleActive(module.routeName) }"
>
<component :is="module.icon" :size="14" :stroke-width="1.75" />
<span>{{ module.label }}</span>
</RouterLink>
</div>
</template>
<button
type="button"
class="es-sidebar__icon-btn"
:class="{ 'es-sidebar__icon-btn--active': activeGroupKey === group.key }"
:aria-label="group.label"
@click="goToGroupHome(group)"
>
<component :is="group.icon" :size="18" :stroke-width="1.75" />
</button>
</a-popover>
</div>
</nav>
</template>
<script setup>
import { computed, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { ChevronRight, LayoutDashboard } from 'lucide-vue-next'
import { computed, ref, watch, nextTick } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ChevronRight, LayoutDashboard, PanelLeftClose, PanelLeftOpen } from 'lucide-vue-next'
import { storeToRefs } from 'pinia'
import { ES_NAVIGATION, findActiveModule } from './navigation'
import { useExternalSystemsStore } from '@/stores/externalSystems'
import { useChatUIStore } from '@/stores/chatUI'
const route = useRoute()
const router = useRouter()
const store = useExternalSystemsStore()
const chatUIStore = useChatUIStore()
const { esNavCollapsed: collapsed } = storeToRefs(chatUIStore)
const sidebarNav = ref(null)
//
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
//
const isOverview = computed(() => route.path === '/external-systems' || route.path === '/external-systems/')
// key
const activeGroupKey = computed(() => findActiveModule(route.path)?.group.key ?? null)
// key channels meta.parentModule route.name
const activeGroupKey = computed(() => findActiveModule(route)?.group.key ?? null)
// + '/'
const isModuleActive = (modulePath) => {
return route.path === modulePath || route.path.startsWith(modulePath + '/')
// channels meta.parentModule route.name
const isModuleActive = (moduleRouteName) => {
const currentName = route.meta?.parentModule || route.name
return currentName === moduleRouteName
}
// /
// /
const userToggledGroups = ref(new Set())
const handleGroupClick = (key) => {
userToggledGroups.value.add(key)
store.toggleGroup(key)
}
// §1.5.4
// route.path activeGroupKey
//
const goToGroupHome = (group) => {
if (group.modules.length > 0) {
router.push({ name: group.modules[0].routeName })
}
}
// channels userToggledGroups
//
watch(
() => route.path,
() => {
const key = findActiveModule(route.path)?.group.key
if (key) {
async () => {
if (collapsed.value) return
const key = findActiveModule(route)?.group.key
if (!key) return
//
if (userToggledGroups.value.has(key)) return
if (!store.expandedGroups[key]) {
store.ensureGroupExpanded(key)
}
await nextTick()
const activeEl = sidebarNav.value?.querySelector('.es-sidebar__module--active')
if (activeEl) {
activeEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
},
{ immediate: true }
)
@ -95,6 +183,37 @@ watch(
overflow-y: auto;
background: var(--gray-0);
border-right: 1px solid var(--gray-150);
transition: width 0.2s ease;
&--collapsed {
width: 48px;
align-items: center;
}
&__collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-bottom: 4px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--gray-500);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--gray-100);
color: var(--gray-900);
}
.es-sidebar--collapsed & {
width: 32px;
height: 32px;
}
}
&__overview {
display: flex;
@ -118,11 +237,26 @@ watch(
color: var(--main-color);
font-weight: 600;
}
.es-sidebar--collapsed & {
justify-content: center;
padding: 8px;
}
}
&__overview-label {
.es-sidebar--collapsed & {
display: none;
}
}
&__group {
display: flex;
flex-direction: column;
.es-sidebar--collapsed & {
align-items: center;
}
}
&__group-header {
@ -172,6 +306,9 @@ watch(
}
&__module {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 5px;
color: var(--gray-600);
@ -191,5 +328,77 @@ watch(
font-weight: 600;
}
}
&__module-icon {
flex-shrink: 0;
}
//
&__icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 2px 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--gray-600);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--gray-100);
color: var(--gray-900);
}
&--active {
background: color-mix(in srgb, var(--main-color) 8%, transparent);
color: var(--main-color);
}
}
}
</style>
<style lang="less">
// popover scoped popover body
.es-sidebar__popover-modules {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 160px;
}
.es-sidebar__popover-title {
padding: 4px 8px;
color: var(--gray-500);
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid var(--gray-150);
margin-bottom: 4px;
}
.es-sidebar__popover-module {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 4px;
color: var(--gray-700);
font-size: 13px;
text-decoration: none;
transition: all 0.15s ease;
&:hover {
background: var(--gray-100);
color: var(--gray-900);
}
&--active {
background: color-mix(in srgb, var(--main-color) 8%, transparent);
color: var(--main-color);
font-weight: 600;
}
}
</style>

View File

@ -9,10 +9,10 @@
</div>
<template v-else>
<!-- 面包屑对齐原型图 §1.5.4显示完整路径 -->
<!-- 面包屑对齐 channels 模块routeName 跳转 + detailLabel 实体名 -->
<a-breadcrumb v-if="breadcrumbItems.length > 0" class="es-page-container__breadcrumb">
<a-breadcrumb-item v-for="(item, index) in breadcrumbItems" :key="index">
<RouterLink v-if="item.path" :to="item.path">{{ item.label }}</RouterLink>
<RouterLink v-if="item.routeName" :to="{ name: item.routeName }">{{ item.label }}</RouterLink>
<span v-else>{{ item.label }}</span>
</a-breadcrumb-item>
</a-breadcrumb>
@ -44,9 +44,11 @@ import { RouterLink, useRoute } from 'vue-router'
import { AlertTriangle } from 'lucide-vue-next'
import { findActiveModule } from './navigation'
defineProps({
const props = defineProps({
title: { type: String, default: '' },
description: { type: String, default: '' }
description: { type: String, default: '' },
//
detailLabel: { type: String, default: '' }
})
const route = useRoute()
@ -64,23 +66,33 @@ const handleRenderRetry = () => {
renderError.value = ''
}
// / {} / {}
// title
// title prop
// / {} / {} / []
// channels PageContainer
// - title
// -
// - routeName
// - detailLabel prop
const breadcrumbItems = computed(() => {
const path = route.path
if (!path.startsWith('/external-systems')) return []
if (path === '/external-systems' || path === '/external-systems/') return []
const items = [{ label: '外部系统集成', path: '/external-systems' }]
// channels ChannelsMain
if (path === '/external-systems' || path === '/external-systems/') {
return [{ label: '外部系统集成' }]
}
const active = findActiveModule(path)
const items = [{ label: '外部系统集成', routeName: 'ExternalSystemsOverview' }]
const active = findActiveModule(route)
if (active) {
//
// modules 退 <span> <RouterLink>
const groupEntryPath = active.group.modules.length > 0 ? active.group.modules[0].path : ''
items.push({ label: active.group.label, path: groupEntryPath })
items.push({ label: active.module.label, path: active.module.path })
// channels
items.push({ label: active.group.label })
items.push({ label: active.module.label, routeName: active.module.routeName })
}
// DetailView detailLabel prop
if (props.detailLabel) {
items.push({ label: props.detailLabel })
}
return items

View File

@ -4,8 +4,40 @@
* 单一数据源 GroupNav 侧边栏与 PageContainer 面包屑共享
* 结构对齐原型图 §1.5.1 菜单层级树
* 一级外部系统集成 二级业务域分组 三级功能页面
*
* 数据源对齐说明 channels 模块一致
* module.routeName 引用 router/index.js 中的路由 name改路由 path 不影响导航
* 改路由 name 时此处需同步更新否则 RouterLink 会报错
* 详情页通过路由 meta.parentModule 显式声明归属不依赖 path 前缀匹配
* module.icon 为侧边栏模块级图标对齐 channels 模块的视觉语言
*
* 抽屉/弹窗类组件不作为独立路由由宿主页面按需挂载故不在导航配置中登记
*/
import { Server, Activity, ShieldCheck, Settings } from 'lucide-vue-next'
import {
Server,
Activity,
ShieldCheck,
Settings,
Database,
Wrench,
Package,
FileInput,
FileText,
HeartPulse,
Gauge,
ScrollText,
KeyRound,
AlertTriangle,
KeyVault,
ShieldAlert,
FlaskConical,
Webhook,
Bell,
Plug,
BookUser,
Sparkles,
Trash2
} from 'lucide-vue-next'
export const ES_NAVIGATION = [
{
@ -13,11 +45,11 @@ export const ES_NAVIGATION = [
label: '资产管理',
icon: Server,
modules: [
{ label: '系统管理', path: '/external-systems/asset-management/systems' },
{ label: '工具管理', path: '/external-systems/asset-management/tools' },
{ label: '环境管理', path: '/external-systems/asset-management/environments' },
{ label: '适配器资产', path: '/external-systems/asset-management/assets' },
{ label: '导入导出', path: '/external-systems/asset-management/import-export' }
{ label: '系统管理', routeName: 'EsSystems', icon: Database },
{ label: '工具管理', routeName: 'EsTools', icon: Wrench },
{ label: '环境管理', routeName: 'EsEnvironments', icon: Package },
{ label: '适配器资产', routeName: 'EsAssets', icon: FileInput },
{ label: '导入导出', routeName: 'EsImportExport', icon: FileText }
]
},
{
@ -25,11 +57,11 @@ export const ES_NAVIGATION = [
label: '运行监控',
icon: Activity,
modules: [
{ label: '执行记录', path: '/external-systems/runtime-monitoring/executions' },
{ label: '健康检查', path: '/external-systems/runtime-monitoring/health-checks' },
{ label: '指标查询', path: '/external-systems/runtime-monitoring/metrics' },
{ label: '审计日志', path: '/external-systems/runtime-monitoring/audit-logs' },
{ label: 'Token 管理', path: '/external-systems/runtime-monitoring/tokens' }
{ label: '执行记录', routeName: 'EsExecutions', icon: Activity },
{ label: '健康检查', routeName: 'EsHealthChecks', icon: HeartPulse },
{ label: '指标查询', routeName: 'EsMetrics', icon: Gauge },
{ label: '审计日志', routeName: 'EsAuditLogs', icon: ScrollText },
{ label: 'Token 管理', routeName: 'EsTokens', icon: KeyRound }
]
},
{
@ -37,11 +69,11 @@ export const ES_NAVIGATION = [
label: '治理管控',
icon: ShieldCheck,
modules: [
{ label: '配额管理', path: '/external-systems/governance-control/quotas' },
{ label: '告警事件', path: '/external-systems/governance-control/alerts' },
{ label: '密钥轮换', path: '/external-systems/governance-control/secret-rotations' },
{ label: '访问规则', path: '/external-systems/governance-control/access-rules' },
{ label: '测试用例', path: '/external-systems/governance-control/test-cases' }
{ label: '配额管理', routeName: 'EsQuotas', icon: Gauge },
{ label: '告警事件', routeName: 'EsAlerts', icon: AlertTriangle },
{ label: '密钥轮换', routeName: 'EsKeyRotations', icon: KeyVault },
{ label: '访问规则', routeName: 'EsAccessRules', icon: ShieldAlert },
{ label: '测试用例', routeName: 'EsTestCases', icon: FlaskConical }
]
},
{
@ -49,29 +81,34 @@ export const ES_NAVIGATION = [
label: '集成配置',
icon: Settings,
modules: [
{ label: 'Webhook 订阅', path: '/external-systems/integration-config/webhooks/subscriptions' },
{ label: 'Webhook 事件', path: '/external-systems/integration-config/webhooks/events' },
{ label: '厂商集成目录', path: '/external-systems/integration-config/vendor-integrations' },
{ label: '集成生成器', path: '/external-systems/integration-config/integration-generator' },
{ label: '通知渠道', path: '/external-systems/integration-config/notification-channels' },
{ label: '认证插件', path: '/external-systems/integration-config/auth-plugins' },
{ label: '适配器元数据', path: '/external-systems/integration-config/adapter-metadata' },
{ label: '回收站', path: '/external-systems/integration-config/recycle-bin' }
{ label: 'Webhook 订阅', routeName: 'EsWebhookSubscriptions', icon: Webhook },
{ label: 'Webhook 事件', routeName: 'EsWebhookEvents', icon: Webhook },
{ label: '厂商集成目录', routeName: 'EsVendorIntegrations', icon: BookUser },
{ label: '集成生成器', routeName: 'EsIntegrationGenerator', icon: Sparkles },
{ label: '通知渠道', routeName: 'EsNotificationChannels', icon: Bell },
{ label: '认证插件', routeName: 'EsAuthPlugins', icon: ShieldCheck },
{ label: '适配器元数据', routeName: 'EsAdapterMetadata', icon: Plug },
{ label: '回收站', routeName: 'EsRecycleBin', icon: Trash2 }
]
}
]
/**
* 根据当前路由路径查找所属分组与模块
* 匹配规则路径等于模块路径或以模块路径 + '/' 开头详情页
* 根据当前路由查找所属分组与模块
*
* @param {string} path - 当前路由 path
* 匹配规则 channels 模块一致
* - 优先读 route.meta.parentModule详情页显式声明父模块避免 path 前缀匹配的脆弱性
* - 否则用 route.name 精确匹配
*
* @param {import('vue-router').RouteLocationNormalized} route - 当前路由对象
* @returns {{ group: object, module: object } | null}
*/
export function findActiveModule(path) {
export function findActiveModule(route) {
const targetName = route?.meta?.parentModule || route?.name
if (!targetName) return null
for (const group of ES_NAVIGATION) {
for (const module of group.modules) {
if (path === module.path || path.startsWith(module.path + '/')) {
if (module.routeName === targetName) {
return { group, module }
}
}

View File

@ -170,6 +170,8 @@
</template>
<script setup>
defineOptions({ name: 'EsAuditLogs' })
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'

View File

@ -1,5 +1,5 @@
<template>
<PageContainer title="执行详情">
<PageContainer title="执行详情" :detail-label="detail?.id ? `#${detail.id}` : ''">
<template #header>
<div class="exec-detail__header">
<div class="exec-detail__header-left">

View File

@ -124,6 +124,8 @@
</template>
<script setup>
defineOptions({ name: 'EsExecutions' })
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'

View File

@ -108,6 +108,63 @@
<div class="metric-card__label">平均耗时</div>
<div class="metric-card__value">{{ formatLatency(aggregateData.avg_latency_ms) }}</div>
</div>
<div class="metric-card">
<div class="metric-card__label">P99 峰值耗时</div>
<div class="metric-card__value">{{ formatLatency(aggregateData.p99_latency_ms) }}</div>
</div>
</div>
<!-- 失败原因分布 -->
<div v-if="aggregateData && aggregateData.failed_calls > 0" class="metrics-section">
<div class="metrics-section__title">失败原因分布</div>
<div class="metrics-cards metrics-cards--sub">
<div class="metric-card metric-card--compact">
<div class="metric-card__label">认证失败</div>
<div class="metric-card__value">{{ formatNumber(aggregateData.auth_failed_calls) }}</div>
<div class="metric-card__ratio">{{ formatRatio(aggregateData.auth_failed_calls, aggregateData.failed_calls) }}</div>
</div>
<div class="metric-card metric-card--compact">
<div class="metric-card__label">SSRF 拦截</div>
<div class="metric-card__value">{{ formatNumber(aggregateData.ssrf_blocked_calls) }}</div>
<div class="metric-card__ratio">{{ formatRatio(aggregateData.ssrf_blocked_calls, aggregateData.failed_calls) }}</div>
</div>
<div class="metric-card metric-card--compact">
<div class="metric-card__label">超时</div>
<div class="metric-card__value">{{ formatNumber(aggregateData.timeout_calls) }}</div>
<div class="metric-card__ratio">{{ formatRatio(aggregateData.timeout_calls, aggregateData.failed_calls) }}</div>
</div>
</div>
</div>
<!-- 治理事件统计 -->
<div
v-if="aggregateData && hasGovernanceEvents(aggregateData)"
class="metrics-section"
>
<div class="metrics-section__title">治理事件</div>
<div class="metrics-cards metrics-cards--sub">
<div class="metric-card metric-card--compact">
<div class="metric-card__label">限流触发</div>
<div class="metric-card__value">{{ formatNumber(aggregateData.throttled_count) }}</div>
</div>
<div class="metric-card metric-card--compact">
<div class="metric-card__label">熔断打开</div>
<div class="metric-card__value">{{ formatNumber(aggregateData.circuit_open_count) }}</div>
</div>
<div class="metric-card metric-card--compact">
<div class="metric-card__label">Token 刷新</div>
<div class="metric-card__value">{{ formatNumber(aggregateData.token_refresh_count) }}</div>
</div>
<div class="metric-card metric-card--compact">
<div class="metric-card__label">Token 刷新失败</div>
<div
class="metric-card__value"
:class="{ 'metric-card__value--failure': aggregateData.token_refresh_failed > 0 }"
>
{{ formatNumber(aggregateData.token_refresh_failed) }}
</div>
</div>
</div>
</div>
</a-tab-pane>
@ -361,6 +418,20 @@ const formatLatency = (ms) => {
return `${ms}ms`
}
const formatRatio = (part, total) => {
if (!total || total === 0) return '0.0%'
return ((part / total) * 100).toFixed(1) + '%'
}
const hasGovernanceEvents = (data) => {
return (
(data.throttled_count ?? 0) > 0 ||
(data.circuit_open_count ?? 0) > 0 ||
(data.token_refresh_count ?? 0) > 0 ||
(data.token_refresh_failed ?? 0) > 0
)
}
const formatFailureRate = (record) => {
// 使 failure_rate
if (record.failure_rate != null) {
@ -759,6 +830,10 @@ onUnmounted(() => {
border: 1px solid var(--gray-150);
border-radius: 8px;
&--compact {
padding: 14px 16px;
}
&__label {
color: var(--gray-600);
font-size: 14px;
@ -779,6 +854,28 @@ onUnmounted(() => {
color: var(--color-error-500);
}
}
&__ratio {
color: var(--gray-500);
font-size: 12px;
margin-top: 4px;
}
}
.metrics-section {
margin-top: 20px;
&__title {
color: var(--gray-700);
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.metrics-cards--sub {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
}
.timeseries-toolbar {

View File

@ -179,6 +179,8 @@
</template>
<script setup>
defineOptions({ name: 'EsTokens' })
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { AlarmClock, History, Ban } from 'lucide-vue-next'

View File

@ -18,12 +18,17 @@ import { unwrap } from './utils'
* - 每个卡片独立维护 AbortController切换时间范围 / 重试时取消未完成请求
* - 30s 超时视为失败按异常链路处理超时抛出 TimeoutError 以便与外部取消区分
* - 403/404/500 等错误在卡片内显示差异化错误信息不跳转全局错误页避免影响其他卡片
* - 500/超时/网络错误自动重试 2 降低偶发抖动对用户的打扰
* - 执行统计 failed 字段按原型图规则计算total - success - pending - running
*/
// 请求超时阈值ms对齐原型图「超过 30s 视为超时失败」
const REQUEST_TIMEOUT_MS = 30_000
// 自动重试配置
const MAX_AUTO_RETRIES = 2
const BASE_RETRY_DELAY_MS = 800
/**
* 包装单个 dashboard 请求注入超时与可取消能力
*
@ -78,6 +83,33 @@ function getStatusCode(error) {
return error?.response?.status ?? null
}
/**
* error 中提取 trace_id优先 error.traceId其次响应体统一错误格式
*/
function getTraceId(error) {
return (
error?.traceId ||
error?.response?.data?.error?.trace_id ||
''
)
}
/**
* 判断错误是否适合自动重试
*/
function isRetryableError(errorType, statusCode) {
if (errorType === 'timeout' || errorType === 'network') return true
if (statusCode === 500 || statusCode === 503) return true
return false
}
/**
* 指数退避延迟
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function useDashboard() {
// ===== 卡片状态 =====
// errorType: '' | 'http' | 'timeout' | 'network'
@ -87,7 +119,8 @@ export function useDashboard() {
loading: false,
error: '',
errorType: '',
statusCode: null
statusCode: null,
traceId: ''
})
const overview = reactive(buildCardState())
@ -114,6 +147,17 @@ export function useDashboard() {
activities: null
}
// ===== 各卡片自动重试计数器 =====
const retryAttempts = {
overview: 0,
systems: 0,
executions: 0,
alerts: 0,
quotas: 0,
health: 0,
activities: 0
}
// ===== 时间参数计算 =====
const timeParams = computed(() => {
const now = new Date()
@ -139,7 +183,7 @@ export function useDashboard() {
}
})
// ===== 通用请求执行器:统一处理状态、错误分类、取消 =====
// ===== 通用请求执行器:统一处理状态、错误分类、取消、自动重试 =====
/**
* @param {string} key - 卡片键
* @param {Object} state - 卡片响应式状态
@ -156,43 +200,74 @@ export function useDashboard() {
const controller = new AbortController()
controllers[key] = controller
// 显式刷新时重置重试计数
retryAttempts[key] = 0
state.loading = true
state.error = ''
state.errorType = ''
state.statusCode = null
state.traceId = ''
/**
* 执行单次请求必要时递归重试
*/
async function attempt(retryCount) {
try {
const response = await callWithTimeout(apiFn, params, controller.signal)
// 被后续请求取消时,不更新状态
if (controller.signal.aborted) return
state.data = unwrap(response)
retryAttempts[key] = 0
} catch (error) {
// 仅当外部信号取消时才不更新状态(由后续请求接管)
// 超时产生的 TimeoutError 不在此列,需正常处理为超时错误
if (controller.signal.aborted) return
const statusCode = getStatusCode(error)
state.statusCode = statusCode
let errorType = 'http'
let errorMsg = ''
if (error.name === 'TimeoutError') {
errorType = 'timeout'
errorMsg = '请求超时,请重试'
} else if (statusCode === 403) {
errorType = 'http'
errorMsg = '没有权限访问此数据'
} else if (statusCode === 404) {
errorType = 'http'
errorMsg = '请求的资源不存在'
} else if (statusCode === 500) {
errorType = 'http'
errorMsg = '服务器内部错误,请稍后重试'
} else if (statusCode) {
errorType = 'http'
errorMsg = error.message || `请求失败 (${statusCode})`
} else {
errorType = 'network'
errorMsg = error.message || '网络异常,请检查连接'
}
// 可重试错误且未达上限:指数退避后递归重试
if (isRetryableError(errorType, statusCode) && retryCount < MAX_AUTO_RETRIES) {
retryAttempts[key] = retryCount + 1
const delay = BASE_RETRY_DELAY_MS * 2 ** retryCount
await sleep(delay)
// 若期间触发新的请求(控制器被替换),终止当前重试链
if (controllers[key] !== controller) return
return attempt(retryCount + 1)
}
state.errorType = errorType
state.error = errorMsg
state.traceId = getTraceId(error)
}
}
try {
const response = await callWithTimeout(apiFn, params, controller.signal)
// 被后续请求取消时,不更新状态
if (controller.signal.aborted) return
state.data = unwrap(response)
} catch (error) {
// 仅当外部信号取消时才不更新状态(由后续请求接管)
// 超时产生的 TimeoutError 不在此列,需正常处理为超时错误
if (controller.signal.aborted) return
const statusCode = getStatusCode(error)
state.statusCode = statusCode
if (error.name === 'TimeoutError') {
state.errorType = 'timeout'
state.error = '请求超时,请重试'
} else if (statusCode === 403) {
state.errorType = 'http'
state.error = '没有权限访问此数据'
} else if (statusCode === 404) {
state.errorType = 'http'
state.error = '请求的资源不存在'
} else if (statusCode === 500) {
state.errorType = 'http'
state.error = '服务器内部错误,请稍后重试'
} else if (statusCode) {
state.errorType = 'http'
state.error = error.message || `请求失败 (${statusCode})`
} else {
state.errorType = 'network'
state.error = error.message || '网络异常,请检查连接'
}
await attempt(0)
} finally {
if (controllers[key] === controller) {
controllers[key] = null

View File

@ -29,6 +29,7 @@ import {
getSystemResourceSummaryApi,
getSystemImpactAnalysisApi
} from '@/apis/external-systems/system_api'
import { getHealthCheckLatestApi } from '@/apis/external-systems/health_check_api'
import { unwrap } from './utils'
/**
@ -69,6 +70,15 @@ export function useExternalSystem() {
const circuitBreakersLoading = ref(false)
const circuitBreakersError = ref(null)
// ===== 健康状态(列表页)=====
// 后端 HealthCheckOutput: { system_id, env_key, health_status, checked_at, ... }
const latestHealth = ref({})
const latestHealthLoading = ref(false)
const latestHealthError = ref(null)
// ===== 全局刷新时间 =====
const lastRefreshedAt = ref(null)
// ===== 治理配置(详情页 6 项)=====
const governance = reactive({
rateLimit: null,
@ -124,6 +134,7 @@ export function useExternalSystem() {
const res = unwrap(await getSystemListApi(params))
list.value = res?.items || []
total.value = res?.total ?? list.value.length
lastRefreshedAt.value = new Date()
} catch (err) {
listError.value = err
list.value = []
@ -196,6 +207,28 @@ export function useExternalSystem() {
}
}
// ===== 健康状态(列表页)=====
async function fetchLatestHealth(systemIds = []) {
if (!systemIds.length) {
latestHealth.value = {}
return
}
latestHealthLoading.value = true
latestHealthError.value = null
try {
const res = unwrap(await getHealthCheckLatestApi({ system_ids: systemIds, limit: systemIds.length })) || {}
const items = res?.items || []
latestHealth.value = Object.fromEntries(
items.map((item) => [item.system_id, item])
)
} catch (err) {
latestHealthError.value = err
latestHealth.value = {}
} finally {
latestHealthLoading.value = false
}
}
// ===== CRUD =====
async function create(data) {
submitting.value = true
@ -544,6 +577,12 @@ export function useExternalSystem() {
circuitBreakersLoading,
circuitBreakersError,
fetchCircuitBreakersStatus,
// 健康状态(列表页)
latestHealth,
latestHealthLoading,
latestHealthError,
fetchLatestHealth,
lastRefreshedAt,
// CRUD
create,
update,