feat: 完成外部系统模块多维度优化迭代
本次提交包含以下核心改进: 1. 统一组件命名规范:为所有列表页组件添加defineOptions配置组件名 2. 重构侧边导航系统:支持折叠态、路由name跳转、自动激活匹配 3. 新增通用基础组件:ErrorRetryAlert错误重试提示、PermissionButton权限按钮 4. 优化列表页错误处理:替换全局错误提示为统一ErrorRetryAlert组件 5. 增强仪表盘能力:添加自动重试机制、traceId展示、多维度指标统计 6. 完善页面容器功能:支持详情页标签、面包屑优化、权限控制 7. 新增系统健康状态监控:集成健康检查API,展示系统健康状态 8. 优化页面交互:加载态、空态、错误态统一视觉语言
This commit is contained in:
parent
70a45c2acc
commit
d38f325577
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 || '操作失败')
|
||||
})
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 || '操作失败')
|
||||
})
|
||||
|
||||
@ -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>
|
||||
@ -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)"
|
||||
|
||||
@ -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=false:a-tooltip 包裹 disabled a-button,hover 显示权限提示
|
||||
*
|
||||
* 透传 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>
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -322,6 +322,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({ name: 'EsQuotas' })
|
||||
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user