ForcePilot/web/src/components/external-systems/asset-management/system/SystemFormModal.vue

771 lines
24 KiB
Vue
Raw Normal View History

<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>