diff --git a/web/src/components/external-systems/asset-management/system/SystemDetailView.vue b/web/src/components/external-systems/asset-management/system/SystemDetailView.vue index 5ca223e6..08e44b86 100644 --- a/web/src/components/external-systems/asset-management/system/SystemDetailView.vue +++ b/web/src/components/external-systems/asset-management/system/SystemDetailView.vue @@ -24,6 +24,10 @@ 克隆 + + + 生成工具 + 清理指标桶 @@ -484,7 +488,8 @@ import { RotateCw, KeyRound, GitBranch, - Trash2 + Trash2, + Wand2 } from 'lucide-vue-next' import { useUserStore } from '@/stores/user' import { useExternalSystem } from '@/composables/external-systems/useExternalSystem' @@ -644,6 +649,14 @@ function handleClone() { cloneModalVisible.value = true } +function handleNavigateToGenerator() { + if (!systemId.value) return + router.push({ + name: 'EsIntegrationGenerator', + query: { system_id: String(systemId.value) } + }) +} + function handleOpenGovernance() { governanceDrawerVisible.value = true } diff --git a/web/src/components/external-systems/asset-management/system/SystemFormModal.vue b/web/src/components/external-systems/asset-management/system/SystemFormModal.vue index 59070b3c..9a3b3933 100644 --- a/web/src/components/external-systems/asset-management/system/SystemFormModal.vue +++ b/web/src/components/external-systems/asset-management/system/SystemFormModal.vue @@ -60,7 +60,13 @@ - + @@ -96,11 +102,11 @@ description="请先在「基础信息」步骤选择适配器类型" /> -
+

适配器「{{ formData.basic.adapter_type }}」连接配置:

@@ -282,6 +288,9 @@ 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' @@ -290,6 +299,7 @@ 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 } }) @@ -302,7 +312,10 @@ const { fetchSchema: fetchAdapterSchema, resetSchema: resetAdapterSchema, overview: adapterOverview, - fetchOverview: fetchAdapterOverview + fetchOverview: fetchAdapterOverview, + authPlugins, + fetchAuthPlugins, + resetAuthPlugins } = useAdapterMetadata() const { @@ -311,11 +324,15 @@ const { 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: { @@ -360,7 +377,14 @@ const formData = reactive({ // ===== Computed ===== const isEdit = computed(() => !!props.systemId) const adapterSchemaData = computed(() => adapterSchemaRef.value) -const authSchemaData = computed(() => authSchemaState.data) +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 ===== @@ -387,13 +411,17 @@ const adapterTypeOptions = computed(() => { ] }) -const authTypeOptions = [ - { label: '无认证', value: 'none' }, - { label: 'Basic Auth', value: 'basic' }, - { label: 'Bearer Token', value: 'bearer' }, - { label: 'API Key', value: 'api_key' }, - { label: 'OAuth2', value: 'oauth2' } -] +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' }, @@ -442,6 +470,9 @@ function resetForm() { currentStep.value = 0 resetAdapterSchema() resetAuthSchema() + resetAuthPlugins() + vendorSupportedAuthTypes.value = null + vendorExtraSchema.value = null } function fillForm(data) { @@ -494,17 +525,68 @@ async function loadAuthSchema(authType, adapterType) { 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 = {} @@ -619,13 +701,33 @@ watch( if (val) { resetForm() // 加载适配器概览(动态填充适配器类型选项) - if (!adapterOverview.value) { + // adapterOverview 默认为空数组 [](truthy),必须用 .length 判断 + if (!adapterOverview.value?.length) { fetchAdapterOverview() } - if (props.initialData) { + // 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) diff --git a/web/src/components/external-systems/asset-management/system/SystemListView.vue b/web/src/components/external-systems/asset-management/system/SystemListView.vue index 34feaf39..c358b8f2 100644 --- a/web/src/components/external-systems/asset-management/system/SystemListView.vue +++ b/web/src/components/external-systems/asset-management/system/SystemListView.vue @@ -189,6 +189,7 @@ v-model:visible="formModalVisible" :system-id="editingId" :initial-data="editingData" + :initial-vendor-meta="initialVendorMeta" :submitting="submitting" @submit="handleSubmit" /> @@ -280,6 +281,7 @@ import { } from 'lucide-vue-next' import { useUserStore } from '@/stores/user' import { useExternalSystem } from '@/composables/external-systems/useExternalSystem' +import { useVendorIntegration } from '@/composables/external-systems/useVendorIntegration' import { SYSTEM_HEALTH_STATUS_MAP as healthStatusMap, SYSTEM_CATEGORY_MAP as categoryDisplayMap @@ -342,12 +344,15 @@ const { batchEnabled } = useExternalSystem() +const { detail: vendorIntegrationDetail, fetchDetail: fetchVendorDetail } = useVendorIntegration() + // ===== Reactive State ===== const selectedRowKeys = ref([]) const selectedSystems = ref([]) const formModalVisible = ref(false) const editingId = ref(null) const editingData = ref(null) +const initialVendorMeta = ref(null) const cloneModalVisible = ref(false) const cloningSource = ref(null) const batchModalVisible = ref(false) @@ -684,15 +689,40 @@ function handleDetail(record) { function handleCreate() { editingId.value = null editingData.value = null + initialVendorMeta.value = null formModalVisible.value = true } function handleEdit(record) { editingId.value = record.id editingData.value = record + initialVendorMeta.value = null formModalVisible.value = true } +// 通过厂商集成目录的 integration_key 跳转过来时,预填适配器与数据源 +async function handleIntegrationKeyQuery(key) { + try { + await fetchVendorDetail(key) + const detail = vendorIntegrationDetail.value + if (!detail) { + message.warning('未找到对应的厂商集成') + return + } + initialVendorMeta.value = { + source_type: key, + adapter_type: detail.adapter_type, + connection_extra_schema: detail.connection_extra_schema, + supported_auth_types: detail.supported_auth_types + } + editingId.value = null + editingData.value = null + formModalVisible.value = true + } catch (err) { + message.error(err?.message || '加载厂商集成失败') + } +} + function handleClone(record) { cloningSource.value = record cloneModalVisible.value = true @@ -861,6 +891,12 @@ onMounted(() => { restoreFromRouteQuery() loadAdapterOptions() loadAll() + // 通过厂商集成目录跳转:携带 integration_key 时预填表单 + const integrationKey = route.query.integration_key + if (integrationKey) { + handleIntegrationKeyQuery(String(integrationKey)) + router.replace({ query: { ...route.query, integration_key: undefined } }) + } }) // 浏览器前进/后退时恢复状态 @@ -873,6 +909,19 @@ watch( } ) +// 厂商集成目录跳转:integration_key 变化时预填表单 +watch( + () => route.query.integration_key, + (key) => { + if (key) handleIntegrationKeyQuery(String(key)) + } +) + +// 弹窗关闭时清空厂商预填元数据,避免下次打开残留 +watch(formModalVisible, (val) => { + if (!val) initialVendorMeta.value = null +}) + // 统一错误提示:create/update/batchClone/batchEnabled 失败时由 composable 写入 submitError watch(submitError, (val) => { if (val) { diff --git a/web/src/components/external-systems/integration-config/integration-generator/IntegrationGeneratorView.vue b/web/src/components/external-systems/integration-config/integration-generator/IntegrationGeneratorView.vue index 63179b62..6bff2fb0 100644 --- a/web/src/components/external-systems/integration-config/integration-generator/IntegrationGeneratorView.vue +++ b/web/src/components/external-systems/integration-config/integration-generator/IntegrationGeneratorView.vue @@ -118,14 +118,72 @@
{{ jsonError }}
+
+ 不同厂商支持的发现选项字段不同:Salesforce 使用 + selected_sobjects,HubSpot 使用 selected_objects +
+ +
+
+ + 已发现 {{ resourceList.length }} 个资源(已选 {{ selectedResources.length }} 个) + + +
+ + + +
+ 发现资源 + + 预览工具 + import { h, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' +import { useRoute, useRouter } from 'vue-router' import { message } from 'ant-design-vue' import { Wand2, Trash2 } from 'lucide-vue-next' import { useUserStore } from '@/stores/user' @@ -317,6 +385,8 @@ const { previewStats, draftTools, selectedToolSlugs, + selectedResources, + resourceList, discoverLoading, previewLoading, generateLoading, @@ -325,6 +395,7 @@ const { error, handleSystemChange, discover, + preview, generate, persist, deleteTool, @@ -334,6 +405,10 @@ const { back } = useIntegrationGenerator() +// ===== 路由(system_id 自动打开弹窗) ===== +const route = useRoute() +const router = useRouter() + // ===== 权限 ===== const userStore = useUserStore() const isAdmin = computed(() => userStore.isAdmin) @@ -408,6 +483,34 @@ const deleteContent = computed(() => { return `确认删除工具「${name}」?此操作不可恢复,删除后相关配置将永久丢失。` }) +// 发现选项 placeholder:按 source_type 动态生成,提示各厂商支持的字段 +const discoveryOptionsPlaceholder = computed(() => { + const st = form.source_type + if (st === 'salesforce') return '{"selected_sobjects": ["Account", "Contact"]}' + if (st === 'hubspot') return '{"selected_objects": ["contacts", "companies"]}' + return '{"key": "value"}' +}) + +// ===== discover 阶段 resource 多选 ===== +const resourceSearchKeyword = ref('') +const resourceColumns = [ + { title: 'Name', key: 'name', dataIndex: 'name', width: 200 }, + { title: 'Label', key: 'label', dataIndex: 'label', width: 200 }, + { title: '查询', key: 'queryable', width: 80, align: 'center' }, + { title: '创建', key: 'createable', width: 80, align: 'center' }, + { title: '更新', key: 'updateable', width: 80, align: 'center' }, + { title: '删除', key: 'deletable', width: 80, align: 'center' } +] +const filteredResourceList = computed(() => { + const kw = resourceSearchKeyword.value.trim().toLowerCase() + if (!kw) return resourceList.value + return resourceList.value.filter( + (r) => + String(r.name || '').toLowerCase().includes(kw) || + String(r.label || '').toLowerCase().includes(kw) + ) +}) + // ===== Watch ===== watch(error, (err) => { if (err) { @@ -472,6 +575,17 @@ async function handleDiscover() { } } +async function handlePreview() { + if (selectedResources.value.length === 0) { + message.warning('请至少选择 1 个资源') + return + } + const ok = await preview() + if (ok) { + currentStep.value = 1 + } +} + async function handleGenerate() { if (selectedToolSlugs.value.length === 0) { message.warning('请至少选择 1 个工具') @@ -537,12 +651,30 @@ async function confirmDeleteTool() { }) } +// ===== 路由 system_id 自动打开弹窗 ===== +async function handleSystemIdQuery(systemId) { + await openModal() + form.system_id = Number(systemId) + await handleSystemChange(Number(systemId)) + router.replace({ query: { ...route.query, system_id: undefined } }) +} + // ===== Lifecycle ===== onMounted(() => { fetchToolList({ limit: listPagination.value.pageSize, offset: 0 }) window.addEventListener('resize', handleResize) + if (route.query.system_id) { + handleSystemIdQuery(route.query.system_id) + } }) +watch( + () => route.query.system_id, + (systemId) => { + if (systemId) handleSystemIdQuery(systemId) + } +) + onBeforeUnmount(() => { window.removeEventListener('resize', handleResize) if (resizeTimer) clearTimeout(resizeTimer) @@ -600,6 +732,36 @@ onBeforeUnmount(() => { font-size: 12px; } +.discovery-options-hint { + margin-top: 4px; + color: #888; + font-size: 12px; + + code { + background: #f5f5f5; + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; + } +} + +.resource-selector { + margin-top: 16px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + &__title { + font-size: 14px; + font-weight: 500; + color: var(--gray-800); + } +} + .draft-alert { margin-bottom: 12px; } diff --git a/web/src/components/external-systems/runtime-monitoring/health-check/HealthCheckView.vue b/web/src/components/external-systems/runtime-monitoring/health-check/HealthCheckView.vue index 93ec7421..b30bfaad 100644 --- a/web/src/components/external-systems/runtime-monitoring/health-check/HealthCheckView.vue +++ b/web/src/components/external-systems/runtime-monitoring/health-check/HealthCheckView.vue @@ -325,7 +325,7 @@