ForcePilot/web/src/components/external-systems/asset-management/system/SystemFormModal.vue
Kris 1f47786dc8 feat(web): 新增集成工具生成与健康检查优化功能
1. 重构健康检查错误处理,将错误提示交由调用方自定义
2. 系统详情页新增生成工具快捷按钮
3. 新增集成生成器资源多选、厂商集成跳转支持
4. 系统表单新增数据源自动补全与动态认证选项
5. 支持通过厂商集成目录快速创建系统
2026-07-18 04:53:42 +08:00

771 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-modal
:open="visible"
:title="isEdit ? '编辑系统' : '新建系统'"
:width="720"
:mask-closable="false"
:destroy-on-close="true"
@cancel="handleCancel"
>
<a-steps :current="currentStep" size="small" class="es-system-form__steps">
<a-step title="基础信息" />
<a-step title="连接配置" />
<a-step title="认证" />
<a-step title="治理" />
</a-steps>
<div class="es-system-form__content">
<!-- 步骤 1基础信息 -->
<div v-show="currentStep === 0" class="es-system-form__step">
<a-form ref="basicFormRef" :model="formData.basic" :rules="basicRules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="Slug" name="slug">
<a-input
v-model:value="formData.basic.slug"
:disabled="isEdit"
placeholder="唯一标识,如 my_system"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="名称" name="name">
<a-input v-model:value="formData.basic.name" placeholder="系统名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="分类" name="category">
<a-select
v-model:value="formData.basic.category"
placeholder="请选择分类"
allow-clear
:options="categoryOptions"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="适配器类型" name="adapter_type">
<a-select
v-model:value="formData.basic.adapter_type"
placeholder="请选择适配器"
allow-clear
:options="adapterTypeOptions"
@change="handleAdapterTypeChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="数据源" name="source_type">
<a-auto-complete
v-model:value="formData.basic.source_type"
:options="sourceTypeOptions"
placeholder="数据源标识,如 salesforce"
:filter-option="(input, option) => option.value.toLowerCase().includes(input.toLowerCase())"
@change="handleSourceTypeChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="超时(秒)" name="timeout">
<a-input-number
v-model:value="formData.basic.timeout"
:min="1"
:max="300"
style="width: 100%"
placeholder="1-300"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="formData.basic.description"
:rows="3"
placeholder="系统描述"
/>
</a-form-item>
<a-form-item name="enabled">
<a-switch v-model:checked="formData.basic.enabled" />
<span class="es-system-form__switch-label">启用系统</span>
</a-form-item>
</a-form>
</div>
<!-- 步骤 2连接配置 -->
<div v-show="currentStep === 1" class="es-system-form__step">
<a-empty
v-if="!formData.basic.adapter_type"
description="请先在「基础信息」步骤选择适配器类型"
/>
<LoadingState v-else-if="adapterSchemaLoading" type="skeleton" :rows="6" />
<div v-else-if="mergedAdapterSchema">
<p class="es-system-form__hint">适配器「{{ formData.basic.adapter_type }}」连接配置:</p>
<DynamicForm
v-model="formData.connection_config"
:schema="mergedAdapterSchema"
:hide-actions="true"
layout="vertical"
/>
</div>
<a-empty v-else description="该适配器未提供配置 Schema" />
</div>
<!-- 步骤 3认证 -->
<div v-show="currentStep === 2" class="es-system-form__step">
<a-form layout="vertical">
<a-form-item label="认证类型">
<a-select
v-model:value="formData.auth_type"
placeholder="请选择认证类型"
allow-clear
:options="authTypeOptions"
@change="handleAuthTypeChange"
/>
</a-form-item>
</a-form>
<a-empty v-if="!formData.auth_type" description="请选择认证类型(可选)" />
<LoadingState v-else-if="authSchemaLoading" type="skeleton" :rows="6" />
<div v-else-if="authSchemaData">
<p class="es-system-form__hint">认证配置:</p>
<DynamicForm
v-model="formData.auth_config"
:schema="authSchemaData"
:hide-actions="true"
layout="vertical"
/>
</div>
<a-empty v-else description="该认证类型未提供配置 Schema" />
</div>
<!-- 步骤 4治理 -->
<div v-show="currentStep === 3" class="es-system-form__step">
<a-form layout="vertical">
<a-divider orientation="left" plain>限流</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="请求数/秒">
<a-input-number
v-model:value="formData.governance.rate_limit.requests_per_second"
:min="0"
style="width: 100%"
placeholder="不限流请留空"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="最大并发">
<a-input-number
v-model:value="formData.governance.rate_limit.max_concurrent"
:min="0"
style="width: 100%"
placeholder="不限流请留空"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left" plain>熔断器</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="失败阈值">
<a-input-number
v-model:value="formData.governance.circuit_breaker.failure_threshold"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="恢复超时(秒)">
<a-input-number
v-model:value="formData.governance.circuit_breaker.recovery_timeout"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left" plain>连接池</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="最大连接数">
<a-input-number
v-model:value="formData.governance.pool_config.max_size"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="最小空闲">
<a-input-number
v-model:value="formData.governance.pool_config.min_idle"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left" plain>可观测性</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="启用追踪">
<a-switch v-model:checked="formData.governance.observability.tracing_enabled" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="启用指标">
<a-switch v-model:checked="formData.governance.observability.metrics_enabled" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="启用日志">
<a-switch v-model:checked="formData.governance.observability.logging_enabled" />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left" plain>重试策略</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="最大重试次数">
<a-input-number
v-model:value="formData.governance.retry_policy.max_attempts"
:min="0"
:max="10"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="退避策略">
<a-select
v-model:value="formData.governance.retry_policy.backoff_strategy"
allow-clear
:options="backoffOptions"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left" plain>密钥引用</a-divider>
<a-form-item label="密钥引用JSON可选">
<a-textarea
v-model:value="secretRefsText"
:rows="4"
placeholder='JSON 格式,如:{"db_password": "secret://db/pwd"}'
/>
</a-form-item>
</a-form>
</div>
</div>
<template #footer>
<a-button @click="handleCancel">取消</a-button>
<a-button v-if="currentStep > 0" @click="handlePrev">上一步</a-button>
<a-button v-if="currentStep < 3" type="primary" @click="handleNext">下一步</a-button>
<a-button
v-else
type="primary"
:loading="submitting"
:disabled="submitting"
@click="handleSubmit"
>
{{ isEdit ? '保存' : '创建' }}
</a-button>
</template>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import { useAdapterMetadata } from '@/composables/external-systems/useAdapterMetadata'
import { useAuthPlugin } from '@/composables/external-systems/useAuthPlugin'
import { useVendorIntegration } from '@/composables/external-systems/useVendorIntegration'
import { unwrap } from '@/composables/external-systems/utils'
import { getVendorConnectionSchemaApi } from '@/apis/external-systems/vendor_integration_api'
import { SYSTEM_CATEGORY_MAP as categoryDisplayMap } from '@/composables/external-systems/constants'
import DynamicForm from '@/components/external-systems/common/DynamicForm.vue'
import LoadingState from '@/components/external-systems/common/LoadingState.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
systemId: { type: [String, Number], default: null },
initialData: { type: Object, default: null },
initialVendorMeta: { type: Object, default: null },
submitting: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible', 'submit'])
// ===== Composables =====
const {
schema: adapterSchemaRef,
schemaLoading: adapterSchemaLoading,
fetchSchema: fetchAdapterSchema,
resetSchema: resetAdapterSchema,
overview: adapterOverview,
fetchOverview: fetchAdapterOverview,
authPlugins,
fetchAuthPlugins,
resetAuthPlugins
} = useAdapterMetadata()
const {
detailSchema: authSchemaState,
fetchDetailSchema: fetchAuthSchema,
resetDetailSchema: resetAuthSchema
} = useAuthPlugin()
const { sourceTypes, fetchListMeta } = useVendorIntegration()
// ===== Reactive State =====
const currentStep = ref(0)
const basicFormRef = ref(null)
const authSchemaLoading = ref(false)
const secretRefsText = ref('')
const vendorSupportedAuthTypes = ref(null)
const vendorExtraSchema = ref(null)
const formData = reactive({
basic: {
slug: '',
name: '',
category: undefined,
adapter_type: undefined,
source_type: '',
timeout: 30,
description: '',
enabled: true
},
connection_config: {},
auth_type: undefined,
auth_config: {},
// 治理配置6 项,对齐后端 CreateSystemRequest 顶级字段
governance: {
rate_limit: {
requests_per_second: undefined,
max_concurrent: undefined
},
circuit_breaker: {
failure_threshold: undefined,
recovery_timeout: undefined
},
pool_config: {
max_size: undefined,
min_idle: undefined
},
observability: {
tracing_enabled: false,
metrics_enabled: false,
logging_enabled: false
},
retry_policy: {
max_attempts: undefined,
backoff_strategy: undefined
}
}
})
// ===== Computed =====
const isEdit = computed(() => !!props.systemId)
const adapterSchemaData = computed(() => adapterSchemaRef.value)
const authSchemaData = computed(() => authSchemaState.data?.schema)
const mergedAdapterSchema = computed(() =>
mergeSchemas(adapterSchemaRef.value, vendorExtraSchema.value)
)
const sourceTypeOptions = computed(() => {
const items = sourceTypes.value || []
return items.map((s) => ({ label: s.source_type, value: s.source_type }))
})
// ===== Constants =====
const categoryOptions = Object.entries(categoryDisplayMap).map(([value, label]) => ({
label,
value
}))
// 适配器类型动态加载(对齐列表页行为)
const adapterTypeOptions = computed(() => {
const items = adapterOverview.value?.items || []
if (items.length) {
return items.map((item) => ({
label: item.display_name || item.adapter_type,
value: item.adapter_type
}))
}
// fallback概览未加载完成时提供常见类型
return [
{ label: 'rest', value: 'rest' },
{ label: 'soap', value: 'soap' },
{ label: 'jdbc', value: 'jdbc' },
{ label: 'grpc', value: 'grpc' }
]
})
const authTypeOptions = computed(() => {
let items = authPlugins.value.map((p) => ({ label: p.display_name, value: p.type }))
if (vendorSupportedAuthTypes.value) {
const allowed = vendorSupportedAuthTypes.value
items = items.filter((i) => allowed.includes(i.value))
}
if (!items.some((i) => i.value === 'none')) {
items.unshift({ label: '无认证', value: 'none' })
}
return items
})
const backoffOptions = [
{ label: '固定间隔', value: 'fixed' },
{ label: '线性退避', value: 'linear' },
{ label: '指数退避', value: 'exponential' }
]
const basicRules = {
slug: [
{ required: true, message: '请输入 Slug' },
{
pattern: /^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$/,
message: '以字母或下划线开头,仅含字母、数字、下划线、连字符,长度 1-128'
}
],
name: [
{ required: true, message: '请输入名称' },
{ max: 128, message: '名称长度不能超过 128' }
],
timeout: [{ type: 'number', min: 1, max: 300, message: '超时范围 1-300 秒' }]
}
// ===== Methods =====
function resetForm() {
formData.basic = {
slug: '',
name: '',
category: undefined,
adapter_type: undefined,
source_type: '',
timeout: 30,
description: '',
enabled: true
}
formData.connection_config = {}
formData.auth_type = undefined
formData.auth_config = {}
formData.governance = {
rate_limit: { requests_per_second: undefined, max_concurrent: undefined },
circuit_breaker: { failure_threshold: undefined, recovery_timeout: undefined },
pool_config: { max_size: undefined, min_idle: undefined },
observability: { tracing_enabled: false, metrics_enabled: false, logging_enabled: false },
retry_policy: { max_attempts: undefined, backoff_strategy: undefined }
}
secretRefsText.value = ''
currentStep.value = 0
resetAdapterSchema()
resetAuthSchema()
resetAuthPlugins()
vendorSupportedAuthTypes.value = null
vendorExtraSchema.value = null
}
function fillForm(data) {
if (!data) return
formData.basic.slug = data.slug || ''
formData.basic.name = data.name || ''
formData.basic.category = data.category
formData.basic.adapter_type = data.adapter_type
formData.basic.source_type = data.source_type || ''
formData.basic.timeout = data.timeout ?? 30
formData.basic.description = data.description || ''
formData.basic.enabled = data.enabled ?? true
formData.connection_config = data.connection_config || {}
formData.auth_type = data.auth_type
formData.auth_config = data.auth_config || {}
if (data.rate_limit) {
formData.governance.rate_limit = { ...data.rate_limit }
}
if (data.circuit_breaker) {
formData.governance.circuit_breaker = { ...data.circuit_breaker }
}
if (data.pool_config) {
formData.governance.pool_config = { ...data.pool_config }
}
if (data.observability) {
formData.governance.observability = { ...data.observability }
}
if (data.retry_policy) {
formData.governance.retry_policy = { ...data.retry_policy }
}
if (data.secret_refs) {
secretRefsText.value = JSON.stringify(data.secret_refs, null, 2)
}
}
async function loadAdapterSchema(type) {
if (!type) {
resetAdapterSchema()
return
}
// schemaLoading 由 useAdapterMetadata.fetchSchema 内部维护
await fetchAdapterSchema(type)
}
async function loadAuthSchema(authType, adapterType) {
if (!authType) {
resetAuthSchema()
return
}
authSchemaLoading.value = true
try {
await fetchAuthSchema(authType, adapterType)
if (authSchemaState.data?.schema) {
authSchemaState.data = {
...authSchemaState.data,
schema: normalizeAuthSchema(authSchemaState.data.schema)
}
}
} finally {
authSchemaLoading.value = false
}
}
async function loadVendorExtraSchema(sourceType) {
if (!sourceType) {
vendorExtraSchema.value = null
return
}
try {
const data = unwrap(await getVendorConnectionSchemaApi(sourceType))
vendorExtraSchema.value = data?.connection_extra_schema || null
} catch {
// 404 等错误静默处理:该厂商无额外连接配置
vendorExtraSchema.value = null
}
}
function mergeSchemas(base, extra) {
if (!base) return extra
if (!extra) return base
return {
...base,
properties: { ...(base.properties || {}), ...(extra.properties || {}) },
required: [...new Set([...(base.required || []), ...(extra.required || [])])]
}
}
function normalizeAuthSchema(schema) {
if (!schema || !schema.properties) return schema
const normalized = { ...schema, properties: { ...schema.properties } }
for (const [name, field] of Object.entries(normalized.properties)) {
if (/private_key/i.test(name)) {
normalized.properties[name] = { ...field, format: 'multiline' }
} else if (!/_url$/.test(name) && /^(password|secret|token)$/i.test(name)) {
normalized.properties[name] = { ...field, format: 'password' }
}
}
return normalized
}
async function handleAdapterTypeChange(value) {
formData.basic.adapter_type = value
formData.connection_config = {}
formData.auth_type = undefined
formData.auth_config = {}
await fetchAuthPlugins(value)
await loadAdapterSchema(value)
}
function handleSourceTypeChange(value) {
formData.connection_config = {}
loadVendorExtraSchema(value)
}
async function handleAuthTypeChange(value) {
formData.auth_type = value
formData.auth_config = {}
await loadAuthSchema(value, formData.basic.adapter_type)
}
async function handleNext() {
if (currentStep.value === 0) {
try {
await basicFormRef.value?.validate()
} catch {
return
}
// 进入步骤 2 时按需加载适配器 Schema
if (formData.basic.adapter_type && !adapterSchemaData.value) {
await loadAdapterSchema(formData.basic.adapter_type)
}
}
if (currentStep.value === 2 && formData.auth_type && !authSchemaData.value) {
await loadAuthSchema(formData.auth_type, formData.basic.adapter_type)
}
currentStep.value += 1
}
function handlePrev() {
currentStep.value -= 1
}
function buildPayload() {
// 后端 CreateSystemRequest/UpdateSystemRequest 中治理配置是顶级字段
// rate_limit/circuit_breaker/pool_config/observability/retry_policy/secret_refs
const payload = {
...formData.basic,
connection_config: formData.connection_config,
auth_type: formData.auth_type || 'none',
auth_config: formData.auth_config
}
// 限流:仅包含有值的字段
const rl = formData.governance.rate_limit
if (rl.requests_per_second != null || rl.max_concurrent != null) {
payload.rate_limit = { ...rl }
}
// 熔断器
const cb = formData.governance.circuit_breaker
if (cb.failure_threshold != null || cb.recovery_timeout != null) {
payload.circuit_breaker = { ...cb }
}
// 连接池
const pc = formData.governance.pool_config
if (pc.max_size != null || pc.min_idle != null) {
payload.pool_config = { ...pc }
}
// 可观测性:任一开关开启则提交
const obs = formData.governance.observability
if (obs.tracing_enabled || obs.metrics_enabled || obs.logging_enabled) {
payload.observability = { ...obs }
}
// 重试策略
const rp = formData.governance.retry_policy
if (rp.max_attempts != null || rp.backoff_strategy) {
payload.retry_policy = { ...rp }
}
// 密钥引用:解析 JSON 文本
if (secretRefsText.value.trim()) {
try {
payload.secret_refs = JSON.parse(secretRefsText.value)
} catch {
// 校验在 handleSubmit 中处理,此处跳过
}
}
return payload
}
async function handleSubmit() {
// 密钥引用 JSON 校验(后端 secret_refs 为 dict[str, Any]
if (secretRefsText.value.trim()) {
let parsed
try {
parsed = JSON.parse(secretRefsText.value)
} catch {
message.error('密钥引用必须为合法 JSON')
return
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
message.error('密钥引用必须为 JSON 对象')
return
}
}
const payload = buildPayload()
emit('submit', {
mode: isEdit.value ? 'edit' : 'create',
id: props.systemId,
data: payload
})
}
function handleCancel() {
emit('update:visible', false)
}
// ===== Watch =====
watch(
() => props.visible,
async (val) => {
if (val) {
resetForm()
// 加载适配器概览(动态填充适配器类型选项)
// adapterOverview 默认为空数组 []truthy必须用 .length 判断
if (!adapterOverview.value?.length) {
fetchAdapterOverview()
}
// authPlugins 无需预加载,等 adapter_type 选择后再 fetchAuthPlugins
// 加载厂商列表元数据(用于 source_type 自动补全)
if (!sourceTypes.value?.length) {
fetchListMeta()
}
if (props.initialVendorMeta) {
vendorSupportedAuthTypes.value = props.initialVendorMeta.supported_auth_types || null
fillForm({
source_type: props.initialVendorMeta.source_type,
adapter_type: props.initialVendorMeta.adapter_type
})
if (formData.basic.adapter_type) {
await loadAdapterSchema(formData.basic.adapter_type)
await fetchAuthPlugins(formData.basic.adapter_type)
}
if (formData.basic.source_type) {
await loadVendorExtraSchema(formData.basic.source_type)
}
} else if (props.initialData) {
fillForm(props.initialData)
if (formData.basic.adapter_type) {
await loadAdapterSchema(formData.basic.adapter_type)
await fetchAuthPlugins(formData.basic.adapter_type)
}
if (formData.auth_type) {
await loadAuthSchema(formData.auth_type, formData.basic.adapter_type)
}
}
}
}
)
</script>
<style scoped lang="less">
.es-system-form {
&__steps {
margin-bottom: 24px;
}
&__content {
min-height: 320px;
max-height: 480px;
overflow-y: auto;
padding-right: 8px;
}
&__step {
padding: 8px 0;
}
&__hint {
margin: 0 0 12px;
color: var(--gray-600);
font-size: 13px;
}
&__switch-label {
margin-left: 8px;
color: var(--gray-700);
font-size: 13px;
}
}
</style>