This is page 7 of 9. Use http://codebase.md/higress-group/himarket?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .cursor │ └── rules │ ├── api-style.mdc │ └── project-architecture.mdc ├── .gitignore ├── build.sh ├── deploy │ ├── docker │ │ ├── docker-compose.yml │ │ └── Docker部署说明.md │ └── helm │ ├── Chart.yaml │ ├── Helm部署说明.md │ ├── templates │ │ ├── _helpers.tpl │ │ ├── himarket-admin-cm.yaml │ │ ├── himarket-admin-deployment.yaml │ │ ├── himarket-admin-service.yaml │ │ ├── himarket-frontend-cm.yaml │ │ ├── himarket-frontend-deployment.yaml │ │ ├── himarket-frontend-service.yaml │ │ ├── himarket-server-cm.yaml │ │ ├── himarket-server-deployment.yaml │ │ ├── himarket-server-service.yaml │ │ ├── mysql.yaml │ │ └── serviceaccount.yaml │ └── values.yaml ├── LICENSE ├── NOTICE ├── pom.xml ├── portal-bootstrap │ ├── Dockerfile │ ├── pom.xml │ └── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ └── alibaba │ │ │ └── apiopenplatform │ │ │ ├── config │ │ │ │ ├── AsyncConfig.java │ │ │ │ ├── FilterConfig.java │ │ │ │ ├── PageConfig.java │ │ │ │ ├── RestTemplateConfig.java │ │ │ │ ├── SecurityConfig.java │ │ │ │ └── SwaggerConfig.java │ │ │ ├── filter │ │ │ │ └── PortalResolvingFilter.java │ │ │ └── PortalApplication.java │ │ └── resources │ │ └── application.yaml │ └── test │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ └── integration │ └── AdministratorAuthIntegrationTest.java ├── portal-dal │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── converter │ │ ├── AdpAIGatewayConfigConverter.java │ │ ├── APIGConfigConverter.java │ │ ├── APIGRefConfigConverter.java │ │ ├── ApiKeyConfigConverter.java │ │ ├── ConsumerAuthConfigConverter.java │ │ ├── GatewayConfigConverter.java │ │ ├── HigressConfigConverter.java │ │ ├── HigressRefConfigConverter.java │ │ ├── HmacConfigConverter.java │ │ ├── JsonConverter.java │ │ ├── JwtConfigConverter.java │ │ ├── NacosRefConfigConverter.java │ │ ├── PortalSettingConfigConverter.java │ │ ├── PortalUiConfigConverter.java │ │ └── ProductIconConverter.java │ ├── entity │ │ ├── Administrator.java │ │ ├── BaseEntity.java │ │ ├── Consumer.java │ │ ├── ConsumerCredential.java │ │ ├── ConsumerRef.java │ │ ├── Developer.java │ │ ├── DeveloperExternalIdentity.java │ │ ├── Gateway.java │ │ ├── NacosInstance.java │ │ ├── Portal.java │ │ ├── PortalDomain.java │ │ ├── Product.java │ │ ├── ProductPublication.java │ │ ├── ProductRef.java │ │ └── ProductSubscription.java │ ├── repository │ │ ├── AdministratorRepository.java │ │ ├── BaseRepository.java │ │ ├── ConsumerCredentialRepository.java │ │ ├── ConsumerRefRepository.java │ │ ├── ConsumerRepository.java │ │ ├── DeveloperExternalIdentityRepository.java │ │ ├── DeveloperRepository.java │ │ ├── GatewayRepository.java │ │ ├── NacosInstanceRepository.java │ │ ├── PortalDomainRepository.java │ │ ├── PortalRepository.java │ │ ├── ProductPublicationRepository.java │ │ ├── ProductRefRepository.java │ │ ├── ProductRepository.java │ │ └── SubscriptionRepository.java │ └── support │ ├── common │ │ ├── Encrypted.java │ │ ├── Encryptor.java │ │ └── User.java │ ├── consumer │ │ ├── AdpAIAuthConfig.java │ │ ├── APIGAuthConfig.java │ │ ├── ApiKeyConfig.java │ │ ├── ConsumerAuthConfig.java │ │ ├── HigressAuthConfig.java │ │ ├── HmacConfig.java │ │ └── JwtConfig.java │ ├── enums │ │ ├── APIGAPIType.java │ │ ├── ConsumerAuthType.java │ │ ├── ConsumerStatus.java │ │ ├── CredentialMode.java │ │ ├── DeveloperAuthType.java │ │ ├── DeveloperStatus.java │ │ ├── DomainType.java │ │ ├── GatewayType.java │ │ ├── GrantType.java │ │ ├── HigressAPIType.java │ │ ├── JwtAlgorithm.java │ │ ├── ProductIconType.java │ │ ├── ProductStatus.java │ │ ├── ProductType.java │ │ ├── ProtocolType.java │ │ ├── PublicKeyFormat.java │ │ ├── SourceType.java │ │ ├── SubscriptionStatus.java │ │ └── UserType.java │ ├── gateway │ │ ├── AdpAIGatewayConfig.java │ │ ├── APIGConfig.java │ │ ├── GatewayConfig.java │ │ └── HigressConfig.java │ ├── portal │ │ ├── AuthCodeConfig.java │ │ ├── IdentityMapping.java │ │ ├── JwtBearerConfig.java │ │ ├── OAuth2Config.java │ │ ├── OidcConfig.java │ │ ├── PortalSettingConfig.java │ │ ├── PortalUiConfig.java │ │ └── PublicKeyConfig.java │ └── product │ ├── APIGRefConfig.java │ ├── HigressRefConfig.java │ ├── NacosRefConfig.java │ └── ProductIcon.java ├── portal-server │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── controller │ │ ├── AdministratorController.java │ │ ├── ConsumerController.java │ │ ├── DeveloperController.java │ │ ├── GatewayController.java │ │ ├── NacosController.java │ │ ├── OAuth2Controller.java │ │ ├── OidcController.java │ │ ├── PortalController.java │ │ └── ProductController.java │ ├── core │ │ ├── advice │ │ │ ├── ExceptionAdvice.java │ │ │ └── ResponseAdvice.java │ │ ├── annotation │ │ │ ├── AdminAuth.java │ │ │ ├── AdminOrDeveloperAuth.java │ │ │ └── DeveloperAuth.java │ │ ├── constant │ │ │ ├── CommonConstants.java │ │ │ ├── IdpConstants.java │ │ │ ├── JwtConstants.java │ │ │ └── Resources.java │ │ ├── event │ │ │ ├── DeveloperDeletingEvent.java │ │ │ ├── PortalDeletingEvent.java │ │ │ └── ProductDeletingEvent.java │ │ ├── exception │ │ │ ├── BusinessException.java │ │ │ └── ErrorCode.java │ │ ├── response │ │ │ └── Response.java │ │ ├── security │ │ │ ├── ContextHolder.java │ │ │ ├── DeveloperAuthenticationProvider.java │ │ │ └── JwtAuthenticationFilter.java │ │ └── utils │ │ ├── IdGenerator.java │ │ ├── PasswordHasher.java │ │ └── TokenUtil.java │ ├── dto │ │ ├── converter │ │ │ ├── InputConverter.java │ │ │ ├── NacosToGatewayToolsConverter.java │ │ │ └── OutputConverter.java │ │ ├── params │ │ │ ├── admin │ │ │ │ ├── AdminCreateParam.java │ │ │ │ ├── AdminLoginParam.java │ │ │ │ └── ResetPasswordParam.java │ │ │ ├── consumer │ │ │ │ ├── CreateConsumerParam.java │ │ │ │ ├── CreateCredentialParam.java │ │ │ │ ├── CreateSubscriptionParam.java │ │ │ │ ├── QueryConsumerParam.java │ │ │ │ ├── QuerySubscriptionParam.java │ │ │ │ └── UpdateCredentialParam.java │ │ │ ├── developer │ │ │ │ ├── CreateDeveloperParam.java │ │ │ │ ├── CreateExternalDeveloperParam.java │ │ │ │ ├── DeveloperLoginParam.java │ │ │ │ ├── QueryDeveloperParam.java │ │ │ │ ├── UnbindExternalIdentityParam.java │ │ │ │ ├── UpdateDeveloperParam.java │ │ │ │ └── UpdateDeveloperStatusParam.java │ │ │ ├── gateway │ │ │ │ ├── ImportGatewayParam.java │ │ │ │ ├── QueryAdpAIGatewayParam.java │ │ │ │ ├── QueryAPIGParam.java │ │ │ │ └── QueryGatewayParam.java │ │ │ ├── nacos │ │ │ │ ├── CreateNacosParam.java │ │ │ │ ├── QueryNacosNamespaceParam.java │ │ │ │ ├── QueryNacosParam.java │ │ │ │ └── UpdateNacosParam.java │ │ │ ├── portal │ │ │ │ ├── BindDomainParam.java │ │ │ │ ├── CreatePortalParam.java │ │ │ │ └── UpdatePortalParam.java │ │ │ └── product │ │ │ ├── CreateProductParam.java │ │ │ ├── CreateProductRefParam.java │ │ │ ├── PublishProductParam.java │ │ │ ├── QueryProductParam.java │ │ │ ├── QueryProductSubscriptionParam.java │ │ │ ├── UnPublishProductParam.java │ │ │ └── UpdateProductParam.java │ │ └── result │ │ ├── AdminResult.java │ │ ├── AdpGatewayInstanceResult.java │ │ ├── AdpMcpServerListResult.java │ │ ├── AdpMCPServerResult.java │ │ ├── APIConfigResult.java │ │ ├── APIGMCPServerResult.java │ │ ├── APIResult.java │ │ ├── AuthResult.java │ │ ├── ConsumerCredentialResult.java │ │ ├── ConsumerResult.java │ │ ├── DeveloperResult.java │ │ ├── GatewayMCPServerResult.java │ │ ├── GatewayResult.java │ │ ├── HigressMCPServerResult.java │ │ ├── IdpResult.java │ │ ├── IdpState.java │ │ ├── IdpTokenResult.java │ │ ├── MCPConfigResult.java │ │ ├── MCPServerResult.java │ │ ├── MseNacosResult.java │ │ ├── NacosMCPServerResult.java │ │ ├── NacosNamespaceResult.java │ │ ├── NacosResult.java │ │ ├── PageResult.java │ │ ├── PortalResult.java │ │ ├── ProductPublicationResult.java │ │ ├── ProductRefResult.java │ │ ├── ProductResult.java │ │ └── SubscriptionResult.java │ └── service │ ├── AdministratorService.java │ ├── AdpAIGatewayService.java │ ├── ConsumerService.java │ ├── DeveloperService.java │ ├── gateway │ │ ├── AdpAIGatewayOperator.java │ │ ├── AIGatewayOperator.java │ │ ├── APIGOperator.java │ │ ├── client │ │ │ ├── AdpAIGatewayClient.java │ │ │ ├── APIGClient.java │ │ │ ├── GatewayClient.java │ │ │ ├── HigressClient.java │ │ │ ├── PopGatewayClient.java │ │ │ └── SLSClient.java │ │ ├── factory │ │ │ └── HTTPClientFactory.java │ │ ├── GatewayOperator.java │ │ └── HigressOperator.java │ ├── GatewayService.java │ ├── IdpService.java │ ├── impl │ │ ├── AdministratorServiceImpl.java │ │ ├── ConsumerServiceImpl.java │ │ ├── DeveloperServiceImpl.java │ │ ├── GatewayServiceImpl.java │ │ ├── IdpServiceImpl.java │ │ ├── NacosServiceImpl.java │ │ ├── OAuth2ServiceImpl.java │ │ ├── OidcServiceImpl.java │ │ ├── PortalServiceImpl.java │ │ └── ProductServiceImpl.java │ ├── NacosService.java │ ├── OAuth2Service.java │ ├── OidcService.java │ ├── PortalService.java │ └── ProductService.java ├── portal-web │ ├── api-portal-admin │ │ ├── .env │ │ ├── .gitignore │ │ ├── bin │ │ │ ├── replace_var.py │ │ │ └── start.sh │ │ ├── Dockerfile │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── nginx.conf │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── proxy.conf │ │ ├── public │ │ │ ├── logo.png │ │ │ └── vite.svg │ │ ├── README.md │ │ ├── src │ │ │ ├── aliyunThemeToken.ts │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── assets │ │ │ │ └── react.svg │ │ │ ├── components │ │ │ │ ├── api-product │ │ │ │ │ ├── ApiProductApiDocs.tsx │ │ │ │ │ ├── ApiProductDashboard.tsx │ │ │ │ │ ├── ApiProductFormModal.tsx │ │ │ │ │ ├── ApiProductLinkApi.tsx │ │ │ │ │ ├── ApiProductOverview.tsx │ │ │ │ │ ├── ApiProductPolicy.tsx │ │ │ │ │ ├── ApiProductPortal.tsx │ │ │ │ │ ├── ApiProductUsageGuide.tsx │ │ │ │ │ ├── SwaggerUIWrapper.css │ │ │ │ │ └── SwaggerUIWrapper.tsx │ │ │ │ ├── common │ │ │ │ │ ├── AdvancedSearch.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── console │ │ │ │ │ ├── GatewayTypeSelector.tsx │ │ │ │ │ ├── ImportGatewayModal.tsx │ │ │ │ │ ├── ImportHigressModal.tsx │ │ │ │ │ ├── ImportMseNacosModal.tsx │ │ │ │ │ └── NacosTypeSelector.tsx │ │ │ │ ├── icons │ │ │ │ │ └── McpServerIcon.tsx │ │ │ │ ├── Layout.tsx │ │ │ │ ├── LayoutWrapper.tsx │ │ │ │ ├── portal │ │ │ │ │ ├── PortalConsumers.tsx │ │ │ │ │ ├── PortalDashboard.tsx │ │ │ │ │ ├── PortalDevelopers.tsx │ │ │ │ │ ├── PortalDomain.tsx │ │ │ │ │ ├── PortalFormModal.tsx │ │ │ │ │ ├── PortalOverview.tsx │ │ │ │ │ ├── PortalPublishedApis.tsx │ │ │ │ │ ├── PortalSecurity.tsx │ │ │ │ │ ├── PortalSettings.tsx │ │ │ │ │ ├── PublicKeyManager.tsx │ │ │ │ │ └── ThirdPartyAuthManager.tsx │ │ │ │ └── subscription │ │ │ │ └── SubscriptionListModal.tsx │ │ │ ├── contexts │ │ │ │ └── LoadingContext.tsx │ │ │ ├── index.css │ │ │ ├── lib │ │ │ │ ├── api.ts │ │ │ │ ├── constant.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── pages │ │ │ │ ├── ApiProductDetail.tsx │ │ │ │ ├── ApiProducts.tsx │ │ │ │ ├── Dashboard.tsx │ │ │ │ ├── GatewayConsoles.tsx │ │ │ │ ├── Login.tsx │ │ │ │ ├── NacosConsoles.tsx │ │ │ │ ├── PortalDetail.tsx │ │ │ │ ├── Portals.tsx │ │ │ │ └── Register.tsx │ │ │ ├── routes │ │ │ │ └── index.tsx │ │ │ ├── types │ │ │ │ ├── api-product.ts │ │ │ │ ├── consumer.ts │ │ │ │ ├── gateway.ts │ │ │ │ ├── index.ts │ │ │ │ ├── portal.ts │ │ │ │ ├── shims-js-yaml.d.ts │ │ │ │ └── subscription.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── api-portal-frontend │ ├── .env │ ├── .gitignore │ ├── .husky │ │ └── pre-commit │ ├── bin │ │ ├── replace_var.py │ │ └── start.sh │ ├── Dockerfile │ ├── eslint.config.js │ ├── index.html │ ├── nginx.conf │ ├── package.json │ ├── postcss.config.js │ ├── proxy.conf │ ├── public │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── MCP.png │ │ ├── MCP.svg │ │ └── vite.svg │ ├── README.md │ ├── src │ │ ├── aliyunThemeToken.ts │ │ ├── App.css │ │ ├── App.tsx │ │ ├── assets │ │ │ ├── aliyun.png │ │ │ ├── github.png │ │ │ ├── google.png │ │ │ └── react.svg │ │ ├── components │ │ │ ├── consumer │ │ │ │ ├── ConsumerBasicInfo.tsx │ │ │ │ ├── CredentialManager.tsx │ │ │ │ ├── index.ts │ │ │ │ └── SubscriptionManager.tsx │ │ │ ├── Layout.tsx │ │ │ ├── Navigation.tsx │ │ │ ├── ProductHeader.tsx │ │ │ ├── SwaggerUIWrapper.css │ │ │ ├── SwaggerUIWrapper.tsx │ │ │ └── UserInfo.tsx │ │ ├── index.css │ │ ├── lib │ │ │ ├── api.ts │ │ │ ├── statusUtils.ts │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── ApiDetail.tsx │ │ │ ├── Apis.tsx │ │ │ ├── Callback.tsx │ │ │ ├── ConsumerDetail.tsx │ │ │ ├── Consumers.tsx │ │ │ ├── GettingStarted.tsx │ │ │ ├── Home.tsx │ │ │ ├── Login.tsx │ │ │ ├── Mcp.tsx │ │ │ ├── McpDetail.tsx │ │ │ ├── OidcCallback.tsx │ │ │ ├── Profile.tsx │ │ │ ├── Register.tsx │ │ │ └── Test.css │ │ ├── router.tsx │ │ ├── types │ │ │ ├── consumer.ts │ │ │ └── index.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── README.md ``` # Files -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/portal/PortalSettings.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import {Card, Form, Input, Select, Switch, Button, Divider, Space, Tag, Table, Modal, message, Tabs} from 'antd' 2 | import {SaveOutlined, PlusOutlined, DeleteOutlined, ExclamationCircleOutlined} from '@ant-design/icons' 3 | import {useState, useMemo} from 'react' 4 | import {Portal, ThirdPartyAuthConfig, AuthenticationType, OidcConfig, OAuth2Config} from '@/types' 5 | import {portalApi} from '@/lib/api' 6 | import {ThirdPartyAuthManager} from './ThirdPartyAuthManager' 7 | 8 | interface PortalSettingsProps { 9 | portal: Portal 10 | onRefresh?: () => void 11 | } 12 | 13 | export function PortalSettings({portal, onRefresh}: PortalSettingsProps) { 14 | const [form] = Form.useForm() 15 | const [loading, setLoading] = useState(false) 16 | const [domainModalVisible, setDomainModalVisible] = useState(false) 17 | const [domainForm] = Form.useForm() 18 | const [domainLoading, setDomainLoading] = useState(false) 19 | 20 | 21 | // 本地OIDC配置状态,避免频繁刷新 22 | // local的有点问题,一切tab就坏了 23 | 24 | 25 | const handleSave = async () => { 26 | try { 27 | setLoading(true) 28 | const values = await form.validateFields() 29 | 30 | await portalApi.updatePortal(portal.portalId, { 31 | name: portal.name, // 保持现有名称不变 32 | description: portal.description, // 保持现有描述不变 33 | portalSettingConfig: { 34 | ...portal.portalSettingConfig, 35 | builtinAuthEnabled: values.builtinAuthEnabled, 36 | oidcAuthEnabled: values.oidcAuthEnabled, 37 | autoApproveDevelopers: values.autoApproveDevelopers, 38 | autoApproveSubscriptions: values.autoApproveSubscriptions, 39 | frontendRedirectUrl: values.frontendRedirectUrl, 40 | }, 41 | portalDomainConfig: portal.portalDomainConfig, 42 | portalUiConfig: portal.portalUiConfig, 43 | }) 44 | 45 | message.success('Portal设置保存成功') 46 | onRefresh?.() 47 | } catch (error) { 48 | message.error('保存Portal设置失败') 49 | } finally { 50 | setLoading(false) 51 | } 52 | } 53 | 54 | const handleSettingUpdate = async (key: string, value: any) => { 55 | try { 56 | await portalApi.updatePortal(portal.portalId, { 57 | ...portal, 58 | portalSettingConfig: { 59 | ...portal.portalSettingConfig, 60 | [key]: value 61 | } 62 | }) 63 | message.success('设置已更新') 64 | onRefresh?.() 65 | } catch (error) { 66 | message.error('设置更新失败') 67 | } 68 | } 69 | 70 | const handleAddDomain = () => { 71 | setDomainModalVisible(true) 72 | domainForm.resetFields() 73 | } 74 | 75 | const handleDomainModalOk = async () => { 76 | try { 77 | setDomainLoading(true) 78 | const values = await domainForm.validateFields() 79 | 80 | const newDomain = { 81 | domain: values.domain, 82 | protocol: values.protocol, 83 | type: 'CUSTOM' 84 | } 85 | 86 | await portalApi.bindDomain(portal.portalId, newDomain) 87 | message.success('域名绑定成功') 88 | onRefresh?.() 89 | setDomainModalVisible(false) 90 | 91 | } catch (error) { 92 | message.error('绑定域名失败') 93 | } finally { 94 | setDomainLoading(false) 95 | } 96 | } 97 | 98 | const handleDomainModalCancel = () => { 99 | setDomainModalVisible(false) 100 | domainForm.resetFields() 101 | } 102 | 103 | const handleDeleteDomain = async (domain: string) => { 104 | Modal.confirm({ 105 | title: '确认解绑', 106 | icon: <ExclamationCircleOutlined/>, 107 | content: `确定要解绑域名 "${domain}" 吗?此操作不可恢复。`, 108 | okText: '确认解绑', 109 | okType: 'danger', 110 | cancelText: '取消', 111 | async onOk() { 112 | try { 113 | await portalApi.unbindDomain(portal.portalId, domain) 114 | message.success('域名解绑成功') 115 | onRefresh?.() 116 | } catch (error) { 117 | message.error('解绑域名失败') 118 | } 119 | }, 120 | }) 121 | } 122 | 123 | // 合并OIDC和OAuth2配置用于统一显示 124 | const thirdPartyAuthConfigs = useMemo((): ThirdPartyAuthConfig[] => { 125 | const configs: ThirdPartyAuthConfig[] = [] 126 | 127 | // 添加OIDC配置 128 | if (portal.portalSettingConfig?.oidcConfigs) { 129 | portal.portalSettingConfig.oidcConfigs.forEach(oidcConfig => { 130 | configs.push({ 131 | ...oidcConfig, 132 | type: AuthenticationType.OIDC 133 | }) 134 | }) 135 | } 136 | 137 | // 添加OAuth2配置 138 | if (portal.portalSettingConfig?.oauth2Configs) { 139 | portal.portalSettingConfig.oauth2Configs.forEach(oauth2Config => { 140 | configs.push({ 141 | ...oauth2Config, 142 | type: AuthenticationType.OAUTH2 143 | }) 144 | }) 145 | } 146 | 147 | return configs 148 | }, [portal.portalSettingConfig?.oidcConfigs, portal.portalSettingConfig?.oauth2Configs]) 149 | 150 | // 第三方认证配置保存函数 151 | const handleSaveThirdPartyAuth = async (configs: ThirdPartyAuthConfig[]) => { 152 | try { 153 | // 分离OIDC和OAuth2配置,去掉type字段 154 | const oidcConfigs = configs 155 | .filter(config => config.type === AuthenticationType.OIDC) 156 | .map(config => { 157 | const { type, ...oidcConfig } = config as (OidcConfig & { type: AuthenticationType.OIDC }) 158 | return oidcConfig 159 | }) 160 | 161 | const oauth2Configs = configs 162 | .filter(config => config.type === AuthenticationType.OAUTH2) 163 | .map(config => { 164 | const { type, ...oauth2Config } = config as (OAuth2Config & { type: AuthenticationType.OAUTH2 }) 165 | return oauth2Config 166 | }) 167 | 168 | const updateData = { 169 | ...portal, 170 | portalSettingConfig: { 171 | ...portal.portalSettingConfig, 172 | // 直接保存分离的配置数组 173 | oidcConfigs: oidcConfigs, 174 | oauth2Configs: oauth2Configs 175 | } 176 | } 177 | 178 | await portalApi.updatePortal(portal.portalId, updateData) 179 | 180 | onRefresh?.() 181 | } catch (error) { 182 | throw error 183 | } 184 | } 185 | 186 | // 域名表格列定义 187 | const domainColumns = [ 188 | { 189 | title: '域名', 190 | dataIndex: 'domain', 191 | key: 'domain', 192 | }, 193 | { 194 | title: '协议', 195 | dataIndex: 'protocol', 196 | key: 'protocol', 197 | }, 198 | { 199 | title: '类型', 200 | dataIndex: 'type', 201 | key: 'type', 202 | render: (type: string) => ( 203 | <Tag color={type === 'DEFAULT' ? 'blue' : 'green'}> 204 | {type === 'DEFAULT' ? '默认域名' : '自定义域名'} 205 | </Tag> 206 | ) 207 | }, 208 | { 209 | title: '操作', 210 | key: 'action', 211 | render: (_: any, record: any) => ( 212 | <Space> 213 | {record.type === 'CUSTOM' && ( 214 | <Button 215 | type="link" 216 | danger 217 | icon={<DeleteOutlined/>} 218 | onClick={() => handleDeleteDomain(record.domain)} 219 | > 220 | 解绑 221 | </Button> 222 | )} 223 | </Space> 224 | ) 225 | } 226 | ] 227 | 228 | const tabItems = [ 229 | { 230 | key: 'auth', 231 | label: '安全设置', 232 | children: ( 233 | <div className="space-y-6"> 234 | {/* 基本安全设置 */} 235 | <div className="grid grid-cols-2 gap-6"> 236 | <Form.Item 237 | name="builtinAuthEnabled" 238 | label="账号密码登录" 239 | valuePropName="checked" 240 | > 241 | <Switch 242 | onChange={(checked) => handleSettingUpdate('builtinAuthEnabled', checked)} 243 | /> 244 | </Form.Item> 245 | {/* <Form.Item 246 | name="oidcAuthEnabled" 247 | label="OIDC认证" 248 | valuePropName="checked" 249 | > 250 | <Switch 251 | onChange={(checked) => handleSettingUpdate('oidcAuthEnabled', checked)} 252 | /> 253 | </Form.Item> */} 254 | <Form.Item 255 | name="autoApproveDevelopers" 256 | label="开发者自动审批" 257 | valuePropName="checked" 258 | > 259 | <Switch 260 | onChange={(checked) => handleSettingUpdate('autoApproveDevelopers', checked)} 261 | /> 262 | </Form.Item> 263 | <Form.Item 264 | name="autoApproveSubscriptions" 265 | label="订阅自动审批" 266 | valuePropName="checked" 267 | > 268 | <Switch 269 | onChange={(checked) => handleSettingUpdate('autoApproveSubscriptions', checked)} 270 | /> 271 | </Form.Item> 272 | </div> 273 | 274 | {/* 第三方认证管理 */} 275 | <Divider/> 276 | <ThirdPartyAuthManager 277 | configs={thirdPartyAuthConfigs} 278 | onSave={handleSaveThirdPartyAuth} 279 | /> 280 | </div> 281 | ) 282 | }, 283 | { 284 | key: 'domain', 285 | label: '域名管理', 286 | children: ( 287 | <div> 288 | <div className="flex justify-between items-center mb-4"> 289 | <div> 290 | <h3 className="text-lg font-medium">域名列表</h3> 291 | <p className="text-sm text-gray-500">管理Portal的域名配置</p> 292 | </div> 293 | <Button 294 | type="primary" 295 | icon={<PlusOutlined/>} 296 | onClick={handleAddDomain} 297 | > 298 | 绑定域名 299 | </Button> 300 | </div> 301 | <Table 302 | columns={domainColumns} 303 | dataSource={portal.portalDomainConfig || []} 304 | rowKey="domain" 305 | pagination={false} 306 | size="small" 307 | /> 308 | </div> 309 | ) 310 | } 311 | ] 312 | 313 | return ( 314 | <div className="p-6 space-y-6"> 315 | <div className="flex justify-between items-center"> 316 | <div> 317 | <h1 className="text-2xl font-bold mb-2">Portal设置</h1> 318 | <p className="text-gray-600">配置Portal的基本设置和高级选项</p> 319 | </div> 320 | <Space> 321 | <Button type="primary" icon={<SaveOutlined/>} loading={loading} onClick={handleSave}> 322 | 保存设置 323 | </Button> 324 | </Space> 325 | </div> 326 | 327 | <Form 328 | form={form} 329 | layout="vertical" 330 | initialValues={{ 331 | portalSettingConfig: portal.portalSettingConfig, 332 | builtinAuthEnabled: portal.portalSettingConfig?.builtinAuthEnabled, 333 | oidcAuthEnabled: portal.portalSettingConfig?.oidcAuthEnabled, 334 | autoApproveDevelopers: portal.portalSettingConfig?.autoApproveDevelopers, 335 | autoApproveSubscriptions: portal.portalSettingConfig?.autoApproveSubscriptions, 336 | frontendRedirectUrl: portal.portalSettingConfig?.frontendRedirectUrl, 337 | portalDomainConfig: portal.portalDomainConfig, 338 | }} 339 | > 340 | <Card> 341 | <Tabs 342 | items={tabItems} 343 | defaultActiveKey="auth" 344 | type="card" 345 | /> 346 | </Card> 347 | </Form> 348 | 349 | {/* 域名绑定模态框 */} 350 | <Modal 351 | title="绑定域名" 352 | open={domainModalVisible} 353 | onOk={handleDomainModalOk} 354 | onCancel={handleDomainModalCancel} 355 | confirmLoading={domainLoading} 356 | okText="绑定" 357 | cancelText="取消" 358 | > 359 | <Form 360 | form={domainForm} 361 | layout="vertical" 362 | > 363 | <Form.Item 364 | name="domain" 365 | label="域名" 366 | rules={[ 367 | {required: true, message: '请输入域名'}, 368 | { 369 | pattern: /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/, 370 | message: '请输入有效的域名格式' 371 | } 372 | ]} 373 | > 374 | <Input placeholder="example.com"/> 375 | </Form.Item> 376 | <Form.Item 377 | name="protocol" 378 | label="协议" 379 | rules={[{required: true, message: '请选择协议'}]} 380 | > 381 | <Select placeholder="请选择协议"> 382 | <Select.Option value="HTTP">HTTP</Select.Option> 383 | <Select.Option value="HTTPS">HTTPS</Select.Option> 384 | </Select> 385 | </Form.Item> 386 | </Form> 387 | </Modal> 388 | </div> 389 | ) 390 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/pages/Portals.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState, useCallback, memo, useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { 4 | Button, 5 | Card, 6 | Avatar, 7 | Dropdown, 8 | Modal, 9 | Form, 10 | Input, 11 | message, 12 | Tooltip, 13 | Pagination, 14 | Skeleton, 15 | } from "antd"; 16 | import { PlusOutlined, MoreOutlined, LinkOutlined } from "@ant-design/icons"; 17 | import type { MenuProps } from "antd"; 18 | import { portalApi } from "../lib/api"; 19 | 20 | import { Portal } from '@/types' 21 | 22 | // 优化的Portal卡片组件 23 | const PortalCard = memo( 24 | ({ 25 | portal, 26 | onNavigate, 27 | fetchPortals, 28 | }: { 29 | portal: Portal; 30 | onNavigate: (id: string) => void; 31 | fetchPortals: () => void; 32 | }) => { 33 | const handleCardClick = useCallback(() => { 34 | onNavigate(portal.portalId); 35 | }, [portal.portalId, onNavigate]); 36 | 37 | const handleLinkClick = useCallback((e: React.MouseEvent) => { 38 | e.stopPropagation(); 39 | }, []); 40 | 41 | const dropdownItems: MenuProps["items"] = [ 42 | 43 | { 44 | key: "delete", 45 | label: "删除", 46 | danger: true, 47 | onClick: (e) => { 48 | e?.domEvent?.stopPropagation(); // 阻止事件冒泡 49 | Modal.confirm({ 50 | title: "删除Portal", 51 | content: "确定要删除该Portal吗?", 52 | onOk: () => { 53 | return handleDeletePortal(portal.portalId); 54 | }, 55 | }); 56 | }, 57 | }, 58 | ]; 59 | 60 | const handleDeletePortal = useCallback((portalId: string) => { 61 | return portalApi.deletePortal(portalId).then(() => { 62 | message.success("Portal删除成功"); 63 | fetchPortals(); 64 | }).catch((error) => { 65 | message.error(error?.response?.data?.message || "删除失败,请稍后重试"); 66 | throw error; 67 | }); 68 | }, [fetchPortals]); 69 | 70 | return ( 71 | <Card 72 | className="cursor-pointer hover:shadow-xl transition-all duration-300 hover:scale-[1.02] border border-gray-100 hover:border-blue-300 bg-gradient-to-br from-white to-gray-50/30" 73 | onClick={handleCardClick} 74 | bodyStyle={{ padding: "20px" }} 75 | > 76 | <div className="flex items-center justify-between mb-6"> 77 | <div className="flex items-center space-x-4"> 78 | <div className="relative"> 79 | <Avatar 80 | size={48} 81 | className="bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg" 82 | style={{ fontSize: "18px", fontWeight: "600" }} 83 | > 84 | {portal.title.charAt(0).toUpperCase()} 85 | </Avatar> 86 | <div className="absolute -bottom-1 -right-1 w-4 h-4 bg-green-400 rounded-full border-2 border-white"></div> 87 | </div> 88 | <div> 89 | <h3 className="text-xl font-bold text-gray-800 mb-1"> 90 | {portal.title} 91 | </h3> 92 | <p className="text-sm text-gray-500">{portal.description}</p> 93 | </div> 94 | </div> 95 | <Dropdown menu={{ items: dropdownItems }} trigger={["click"]}> 96 | <Button 97 | type="text" 98 | icon={<MoreOutlined />} 99 | onClick={(e) => e.stopPropagation()} 100 | className="hover:bg-gray-100 rounded-full" 101 | /> 102 | </Dropdown> 103 | </div> 104 | 105 | <div className="space-y-6"> 106 | <div className="flex items-center space-x-3 p-3 bg-blue-50 rounded-lg border border-blue-100"> 107 | <LinkOutlined className="h-4 w-4 text-blue-500" /> 108 | <Tooltip 109 | title={portal.portalDomainConfig?.[0].domain} 110 | placement="top" 111 | color="#000" 112 | > 113 | <a 114 | href={`http://${portal.portalDomainConfig?.[0].domain}`} 115 | target="_blank" 116 | rel="noopener noreferrer" 117 | className="text-blue-600 hover:text-blue-700 font-medium text-sm" 118 | onClick={handleLinkClick} 119 | style={{ 120 | display: "inline-block", 121 | maxWidth: 200, 122 | overflow: "hidden", 123 | textOverflow: "ellipsis", 124 | whiteSpace: "nowrap", 125 | verticalAlign: "bottom", 126 | cursor: "pointer", 127 | }} 128 | > 129 | {portal.portalDomainConfig?.[0].domain} 130 | </a> 131 | </Tooltip> 132 | </div> 133 | 134 | <div className="space-y-3"> 135 | {/* 第一行:账号密码登录 + 开发者自动审批 */} 136 | <div className="grid grid-cols-2 gap-4"> 137 | <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md"> 138 | <span className="text-xs font-medium text-gray-600"> 139 | 账号密码登录 140 | </span> 141 | <span 142 | className={`px-2 py-1 rounded-full text-xs font-medium ${ 143 | portal.portalSettingConfig?.builtinAuthEnabled 144 | ? "bg-green-100 text-green-700" 145 | : "bg-red-100 text-red-700" 146 | }`} 147 | > 148 | {portal.portalSettingConfig?.builtinAuthEnabled 149 | ? "支持" 150 | : "不支持"} 151 | </span> 152 | </div> 153 | 154 | <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md"> 155 | <span className="text-xs font-medium text-gray-600"> 156 | 开发者自动审批 157 | </span> 158 | <span 159 | className={`px-2 py-1 rounded-full text-xs font-medium ${ 160 | portal.portalSettingConfig?.autoApproveDevelopers 161 | ? "bg-green-100 text-green-700" 162 | : "bg-yellow-100 text-yellow-700" 163 | }`} 164 | > 165 | {portal.portalSettingConfig?.autoApproveDevelopers 166 | ? "是" 167 | : "否"} 168 | </span> 169 | </div> 170 | </div> 171 | 172 | {/* 第二行:订阅自动审批 + 域名配置 */} 173 | <div className="grid grid-cols-2 gap-4"> 174 | <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md"> 175 | <span className="text-xs font-medium text-gray-600"> 176 | 订阅自动审批 177 | </span> 178 | <span 179 | className={`px-2 py-1 rounded-full text-xs font-medium ${ 180 | portal.portalSettingConfig?.autoApproveSubscriptions 181 | ? "bg-green-100 text-green-700" 182 | : "bg-yellow-100 text-yellow-700" 183 | }`} 184 | > 185 | {portal.portalSettingConfig?.autoApproveSubscriptions 186 | ? "是" 187 | : "否"} 188 | </span> 189 | </div> 190 | 191 | <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md"> 192 | <span className="text-xs font-medium text-gray-600"> 193 | 域名配置 194 | </span> 195 | <span className="px-2 py-1 bg-purple-100 text-purple-700 rounded-full text-xs font-medium"> 196 | {portal.portalDomainConfig?.length || 0}个 197 | </span> 198 | </div> 199 | </div> 200 | </div> 201 | 202 | <div className="text-center pt-4 border-t border-gray-100"> 203 | <div className="inline-flex items-center space-x-2 text-sm text-gray-500 hover:text-blue-600 transition-colors"> 204 | <span onClick={handleCardClick}>点击查看详情</span> 205 | <svg 206 | className="w-4 h-4" 207 | fill="none" 208 | stroke="currentColor" 209 | viewBox="0 0 24 24" 210 | > 211 | <path 212 | strokeLinecap="round" 213 | strokeLinejoin="round" 214 | strokeWidth={2} 215 | d="M9 5l7 7-7 7" 216 | /> 217 | </svg> 218 | </div> 219 | </div> 220 | </div> 221 | </Card> 222 | ); 223 | } 224 | ); 225 | 226 | PortalCard.displayName = "PortalCard"; 227 | 228 | export default function Portals() { 229 | const navigate = useNavigate(); 230 | const [portals, setPortals] = useState<Portal[]>([]); 231 | const [loading, setLoading] = useState<boolean>(true); // 初始状态为 loading 232 | const [error, setError] = useState<string | null>(null); 233 | const [isModalVisible, setIsModalVisible] = useState<boolean>(false); 234 | const [form] = Form.useForm(); 235 | const [pagination, setPagination] = useState({ 236 | current: 1, 237 | pageSize: 12, 238 | total: 0, 239 | }); 240 | 241 | const fetchPortals = useCallback((page = 1, size = 12) => { 242 | setLoading(true); 243 | portalApi.getPortals({ page, size }).then((res: any) => { 244 | const list = res?.data?.content || []; 245 | const portals: Portal[] = list.map((item: any) => ({ 246 | portalId: item.portalId, 247 | name: item.name, 248 | title: item.name, 249 | description: item.description, 250 | adminId: item.adminId, 251 | portalSettingConfig: item.portalSettingConfig, 252 | portalUiConfig: item.portalUiConfig, 253 | portalDomainConfig: item.portalDomainConfig || [], 254 | })); 255 | setPortals(portals); 256 | setPagination({ 257 | current: page, 258 | pageSize: size, 259 | total: res?.data?.totalElements || 0, 260 | }); 261 | }).catch((err: any) => { 262 | setError(err?.message || "加载失败"); 263 | }).finally(() => { 264 | setLoading(false); 265 | }); 266 | }, []); 267 | 268 | useEffect(() => { 269 | setError(null); 270 | fetchPortals(1, 12); 271 | }, [fetchPortals]); 272 | 273 | // 处理分页变化 274 | const handlePaginationChange = (page: number, pageSize: number) => { 275 | fetchPortals(page, pageSize); 276 | }; 277 | 278 | const handleCreatePortal = useCallback(() => { 279 | setIsModalVisible(true); 280 | }, []); 281 | 282 | const handleModalOk = useCallback(async () => { 283 | try { 284 | const values = await form.validateFields(); 285 | setLoading(true); 286 | 287 | const newPortal = { 288 | name: values.name, 289 | title: values.title, 290 | description: values.description, 291 | }; 292 | 293 | await portalApi.createPortal(newPortal); 294 | message.success("Portal创建成功"); 295 | setIsModalVisible(false); 296 | form.resetFields(); 297 | 298 | fetchPortals() 299 | } catch (error: any) { 300 | // message.error(error?.message || "创建失败"); 301 | } finally { 302 | setLoading(false); 303 | } 304 | }, [form]); 305 | 306 | const handleModalCancel = useCallback(() => { 307 | setIsModalVisible(false); 308 | form.resetFields(); 309 | }, [form]); 310 | 311 | const handlePortalClick = useCallback( 312 | (portalId: string) => { 313 | navigate(`/portals/detail?id=${portalId}`); 314 | }, 315 | [navigate] 316 | ); 317 | 318 | return ( 319 | <div className="space-y-6"> 320 | <div className="flex items-center justify-between"> 321 | <div> 322 | <h1 className="text-3xl font-bold tracking-tight">Portal</h1> 323 | <p className="text-gray-500 mt-2">管理和配置您的开发者门户</p> 324 | </div> 325 | <Button 326 | type="primary" 327 | icon={<PlusOutlined />} 328 | onClick={handleCreatePortal} 329 | > 330 | 创建 Portal 331 | </Button> 332 | </div> 333 | {error && <div className="text-red-500">{error}</div>} 334 | 335 | {loading ? ( 336 | <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> 337 | {Array.from({ length: pagination.pageSize || 12 }).map((_, index) => ( 338 | <div key={index} className="h-full rounded-lg shadow-lg bg-white p-4"> 339 | <div className="flex items-start space-x-4"> 340 | <Skeleton.Avatar size={48} active /> 341 | <div className="flex-1 min-w-0"> 342 | <div className="flex items-center justify-between mb-2"> 343 | <Skeleton.Input active size="small" style={{ width: 120 }} /> 344 | <Skeleton.Input active size="small" style={{ width: 60 }} /> 345 | </div> 346 | <Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} /> 347 | <Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} /> 348 | <div className="flex items-center justify-between"> 349 | <Skeleton.Input active size="small" style={{ width: 60 }} /> 350 | <Skeleton.Input active size="small" style={{ width: 80 }} /> 351 | </div> 352 | </div> 353 | </div> 354 | </div> 355 | ))} 356 | </div> 357 | ) : ( 358 | <> 359 | <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> 360 | {portals.map((portal) => ( 361 | <PortalCard 362 | key={portal.portalId} 363 | portal={portal} 364 | onNavigate={handlePortalClick} 365 | fetchPortals={() => fetchPortals(pagination.current, pagination.pageSize)} 366 | /> 367 | ))} 368 | </div> 369 | 370 | {pagination.total > 0 && ( 371 | <div className="flex justify-center mt-6"> 372 | <Pagination 373 | current={pagination.current} 374 | pageSize={pagination.pageSize} 375 | total={pagination.total} 376 | onChange={handlePaginationChange} 377 | showSizeChanger 378 | showQuickJumper 379 | showTotal={(total) => `共 ${total} 条`} 380 | pageSizeOptions={['6', '12', '24', '48']} 381 | /> 382 | </div> 383 | )} 384 | </> 385 | )} 386 | 387 | <Modal 388 | title="创建Portal" 389 | open={isModalVisible} 390 | onOk={handleModalOk} 391 | onCancel={handleModalCancel} 392 | confirmLoading={loading} 393 | width={600} 394 | > 395 | <Form form={form} layout="vertical"> 396 | <Form.Item 397 | name="name" 398 | label="名称" 399 | rules={[{ required: true, message: "请输入Portal名称" }]} 400 | > 401 | <Input placeholder="请输入Portal名称" /> 402 | </Form.Item> 403 | 404 | {/* <Form.Item 405 | name="title" 406 | label="标题" 407 | rules={[{ required: true, message: "请输入Portal标题" }]} 408 | > 409 | <Input placeholder="请输入Portal标题" /> 410 | </Form.Item> */} 411 | 412 | <Form.Item 413 | name="description" 414 | label="描述" 415 | rules={[{ message: "请输入描述" }]} 416 | > 417 | <Input.TextArea rows={3} placeholder="请输入Portal描述" /> 418 | </Form.Item> 419 | </Form> 420 | </Modal> 421 | </div> 422 | ); 423 | } 424 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/DeveloperServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.impl; 21 | 22 | import cn.hutool.core.util.BooleanUtil; 23 | import cn.hutool.core.util.StrUtil; 24 | import com.alibaba.apiopenplatform.core.constant.Resources; 25 | import com.alibaba.apiopenplatform.core.event.DeveloperDeletingEvent; 26 | import com.alibaba.apiopenplatform.core.event.PortalDeletingEvent; 27 | import com.alibaba.apiopenplatform.core.utils.TokenUtil; 28 | import com.alibaba.apiopenplatform.dto.params.developer.CreateDeveloperParam; 29 | import com.alibaba.apiopenplatform.dto.params.developer.CreateExternalDeveloperParam; 30 | import com.alibaba.apiopenplatform.dto.params.developer.QueryDeveloperParam; 31 | import com.alibaba.apiopenplatform.dto.params.developer.UpdateDeveloperParam; 32 | import com.alibaba.apiopenplatform.dto.result.AuthResult; 33 | import com.alibaba.apiopenplatform.dto.result.DeveloperResult; 34 | import com.alibaba.apiopenplatform.dto.result.PageResult; 35 | import com.alibaba.apiopenplatform.entity.Developer; 36 | import com.alibaba.apiopenplatform.entity.Portal; 37 | import com.alibaba.apiopenplatform.repository.DeveloperRepository; 38 | import com.alibaba.apiopenplatform.repository.PortalRepository; 39 | import com.alibaba.apiopenplatform.service.DeveloperService; 40 | import com.alibaba.apiopenplatform.core.utils.PasswordHasher; 41 | import com.alibaba.apiopenplatform.core.utils.IdGenerator; 42 | import com.alibaba.apiopenplatform.repository.DeveloperExternalIdentityRepository; 43 | import com.alibaba.apiopenplatform.entity.DeveloperExternalIdentity; 44 | import com.alibaba.apiopenplatform.support.enums.DeveloperAuthType; 45 | import com.alibaba.apiopenplatform.support.enums.DeveloperStatus; 46 | import lombok.RequiredArgsConstructor; 47 | import lombok.extern.slf4j.Slf4j; 48 | import org.springframework.context.ApplicationEventPublisher; 49 | import org.springframework.context.event.EventListener; 50 | import org.springframework.data.domain.Page; 51 | import org.springframework.data.domain.Pageable; 52 | import org.springframework.data.jpa.domain.Specification; 53 | import org.springframework.scheduling.annotation.Async; 54 | import org.springframework.stereotype.Service; 55 | import org.springframework.transaction.annotation.Transactional; 56 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 57 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 58 | import com.alibaba.apiopenplatform.core.security.ContextHolder; 59 | 60 | import javax.persistence.criteria.Predicate; 61 | import java.util.*; 62 | import javax.servlet.http.HttpServletRequest; 63 | 64 | @Service 65 | @RequiredArgsConstructor 66 | @Slf4j 67 | @Transactional 68 | public class DeveloperServiceImpl implements DeveloperService { 69 | 70 | private final DeveloperRepository developerRepository; 71 | 72 | private final DeveloperExternalIdentityRepository externalRepository; 73 | 74 | private final PortalRepository portalRepository; 75 | 76 | private final ContextHolder contextHolder; 77 | 78 | private final ApplicationEventPublisher eventPublisher; 79 | 80 | @Override 81 | public AuthResult registerDeveloper(CreateDeveloperParam param) { 82 | DeveloperResult developer = createDeveloper(param); 83 | 84 | // 检查是否自动审批 85 | String portalId = contextHolder.getPortal(); 86 | Portal portal = findPortal(portalId); 87 | boolean autoApprove = portal.getPortalSettingConfig() != null 88 | && BooleanUtil.isTrue(portal.getPortalSettingConfig().getAutoApproveDevelopers()); 89 | 90 | if (autoApprove) { 91 | String token = generateToken(developer.getDeveloperId()); 92 | return AuthResult.of(token, TokenUtil.getTokenExpiresIn()); 93 | } 94 | return null; 95 | } 96 | 97 | @Override 98 | public DeveloperResult createDeveloper(CreateDeveloperParam param) { 99 | String portalId = contextHolder.getPortal(); 100 | developerRepository.findByPortalIdAndUsername(portalId, param.getUsername()).ifPresent(developer -> { 101 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.DEVELOPER, param.getUsername())); 102 | }); 103 | 104 | Developer developer = param.convertTo(); 105 | developer.setDeveloperId(generateDeveloperId()); 106 | developer.setPortalId(portalId); 107 | developer.setPasswordHash(PasswordHasher.hash(param.getPassword())); 108 | 109 | Portal portal = findPortal(portalId); 110 | boolean autoApprove = portal.getPortalSettingConfig() != null 111 | && BooleanUtil.isTrue(portal.getPortalSettingConfig().getAutoApproveDevelopers()); 112 | developer.setStatus(autoApprove ? DeveloperStatus.APPROVED : DeveloperStatus.PENDING); 113 | developer.setAuthType(DeveloperAuthType.BUILTIN); 114 | 115 | developerRepository.save(developer); 116 | return new DeveloperResult().convertFrom(developer); 117 | } 118 | 119 | @Override 120 | public AuthResult login(String username, String password) { 121 | String portalId = contextHolder.getPortal(); 122 | Developer developer = developerRepository.findByPortalIdAndUsername(portalId, username) 123 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.DEVELOPER, username)); 124 | 125 | if (!DeveloperStatus.APPROVED.equals(developer.getStatus())) { 126 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "账号审批中"); 127 | } 128 | 129 | if (!PasswordHasher.verify(password, developer.getPasswordHash())) { 130 | throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); 131 | } 132 | 133 | String token = generateToken(developer.getDeveloperId()); 134 | return AuthResult.builder() 135 | .accessToken(token) 136 | .expiresIn(TokenUtil.getTokenExpiresIn()) 137 | .build(); 138 | } 139 | 140 | @Override 141 | public void existsDeveloper(String developerId) { 142 | developerRepository.findByDeveloperId(developerId) 143 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.DEVELOPER, developerId)); 144 | } 145 | 146 | @Override 147 | public DeveloperResult createExternalDeveloper(CreateExternalDeveloperParam param) { 148 | Developer developer = Developer.builder() 149 | .developerId(IdGenerator.genDeveloperId()) 150 | .portalId(contextHolder.getPortal()) 151 | .username(buildExternalName(param.getProvider(), param.getDisplayName())) 152 | .email(param.getEmail()) 153 | // 默认APPROVED 154 | .status(DeveloperStatus.APPROVED) 155 | .build(); 156 | 157 | DeveloperExternalIdentity externalIdentity = DeveloperExternalIdentity.builder() 158 | .provider(param.getProvider()) 159 | .subject(param.getSubject()) 160 | .displayName(param.getDisplayName()) 161 | .authType(param.getAuthType()) 162 | .developer(developer) 163 | .build(); 164 | 165 | developerRepository.save(developer); 166 | externalRepository.save(externalIdentity); 167 | return new DeveloperResult().convertFrom(developer); 168 | } 169 | 170 | @Override 171 | public DeveloperResult getExternalDeveloper(String provider, String subject) { 172 | return externalRepository.findByProviderAndSubject(provider, subject) 173 | .map(o -> new DeveloperResult().convertFrom(o.getDeveloper())) 174 | .orElse(null); 175 | } 176 | 177 | private String buildExternalName(String provider, String subject) { 178 | return StrUtil.format("{}_{}", provider, subject); 179 | } 180 | 181 | @Override 182 | public void deleteDeveloper(String developerId) { 183 | eventPublisher.publishEvent(new DeveloperDeletingEvent(developerId)); 184 | externalRepository.deleteByDeveloper_DeveloperId(developerId); 185 | developerRepository.findByDeveloperId(developerId).ifPresent(developerRepository::delete); 186 | } 187 | 188 | @Override 189 | public DeveloperResult getDeveloper(String developerId) { 190 | Developer developer = findDeveloper(developerId); 191 | return new DeveloperResult().convertFrom(developer); 192 | } 193 | 194 | @Override 195 | public PageResult<DeveloperResult> listDevelopers(QueryDeveloperParam param, Pageable pageable) { 196 | if (contextHolder.isDeveloper()) { 197 | param.setPortalId(contextHolder.getPortal()); 198 | } 199 | Page<Developer> developers = developerRepository.findAll(buildSpecification(param), pageable); 200 | return new PageResult<DeveloperResult>().convertFrom(developers, developer -> new DeveloperResult().convertFrom(developer)); 201 | } 202 | 203 | @Override 204 | public void setDeveloperStatus(String developerId, DeveloperStatus status) { 205 | Developer developer = findDeveloper(developerId); 206 | developer.setStatus(status); 207 | developerRepository.save(developer); 208 | } 209 | 210 | @Override 211 | @Transactional 212 | public boolean resetPassword(String developerId, String oldPassword, String newPassword) { 213 | Developer developer = findDeveloper(developerId); 214 | 215 | if (!PasswordHasher.verify(oldPassword, developer.getPasswordHash())) { 216 | throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); 217 | } 218 | 219 | developer.setPasswordHash(PasswordHasher.hash(newPassword)); 220 | developerRepository.save(developer); 221 | return true; 222 | } 223 | 224 | @Override 225 | public boolean updateProfile(UpdateDeveloperParam param) { 226 | Developer developer = findDeveloper(contextHolder.getUser()); 227 | 228 | String username = param.getUsername(); 229 | if (username != null && !username.equals(developer.getUsername())) { 230 | if (developerRepository.findByPortalIdAndUsername(developer.getPortalId(), username).isPresent()) { 231 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.DEVELOPER, username)); 232 | } 233 | } 234 | param.update(developer); 235 | 236 | developerRepository.save(developer); 237 | return true; 238 | } 239 | 240 | @EventListener 241 | @Async("taskExecutor") 242 | public void handlePortalDeletion(PortalDeletingEvent event) { 243 | String portalId = event.getPortalId(); 244 | List<Developer> developers = developerRepository.findByPortalId(portalId); 245 | developers.forEach(developer -> deleteDeveloper(developer.getDeveloperId())); 246 | } 247 | 248 | private String generateToken(String developerId) { 249 | return TokenUtil.generateDeveloperToken(developerId); 250 | } 251 | 252 | private Developer createExternalDeveloper(String providerName, String providerSubject, String email, String displayName, String rawInfoJson) { 253 | String portalId = contextHolder.getPortal(); 254 | String username = generateUniqueUsername(portalId, displayName, providerName, providerSubject); 255 | 256 | Developer developer = Developer.builder() 257 | .developerId(generateDeveloperId()) 258 | .portalId(portalId) 259 | .username(username) 260 | .email(email) 261 | .status(DeveloperStatus.APPROVED) 262 | .authType(DeveloperAuthType.OIDC) 263 | .build(); 264 | developer = developerRepository.save(developer); 265 | 266 | DeveloperExternalIdentity ext = DeveloperExternalIdentity.builder() 267 | .provider(providerName) 268 | .subject(providerSubject) 269 | .displayName(displayName) 270 | .rawInfoJson(rawInfoJson) 271 | .developer(developer) 272 | .build(); 273 | externalRepository.save(ext); 274 | return developer; 275 | } 276 | 277 | private String generateUniqueUsername(String portalId, String displayName, String providerName, String providerSubject) { 278 | String username = displayName != null ? displayName : providerName + "_" + providerSubject; 279 | String originalUsername = username; 280 | int suffix = 1; 281 | while (developerRepository.findByPortalIdAndUsername(portalId, username).isPresent()) { 282 | username = originalUsername + "_" + suffix; 283 | suffix++; 284 | } 285 | return username; 286 | } 287 | 288 | private String generateDeveloperId() { 289 | return IdGenerator.genDeveloperId(); 290 | } 291 | 292 | private Developer findDeveloper(String developerId) { 293 | return developerRepository.findByDeveloperId(developerId) 294 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.DEVELOPER, developerId)); 295 | } 296 | 297 | private Portal findPortal(String portalId) { 298 | return portalRepository.findByPortalId(portalId) 299 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId)); 300 | } 301 | 302 | private Specification<Developer> buildSpecification(QueryDeveloperParam param) { 303 | return (root, query, cb) -> { 304 | List<Predicate> predicates = new ArrayList<>(); 305 | 306 | if (StrUtil.isNotBlank(param.getPortalId())) { 307 | predicates.add(cb.equal(root.get("portalId"), param.getPortalId())); 308 | } 309 | if (StrUtil.isNotBlank(param.getUsername())) { 310 | String likePattern = "%" + param.getUsername() + "%"; 311 | predicates.add(cb.like(root.get("username"), likePattern)); 312 | } 313 | if (param.getStatus() != null) { 314 | predicates.add(cb.equal(root.get("status"), param.getStatus())); 315 | } 316 | 317 | return cb.and(predicates.toArray(new Predicate[0])); 318 | }; 319 | } 320 | 321 | @Override 322 | public void logout(HttpServletRequest request) { 323 | // 使用TokenUtil处理登出逻辑 324 | com.alibaba.apiopenplatform.core.utils.TokenUtil.revokeToken(request); 325 | } 326 | 327 | @Override 328 | public DeveloperResult getCurrentDeveloperInfo() { 329 | String currentUserId = contextHolder.getUser(); 330 | Developer developer = findDeveloper(currentUserId); 331 | return new DeveloperResult().convertFrom(developer); 332 | } 333 | 334 | @Override 335 | public boolean changeCurrentDeveloperPassword(String oldPassword, String newPassword) { 336 | String currentUserId = contextHolder.getUser(); 337 | return resetPassword(currentUserId, oldPassword, newPassword); 338 | } 339 | 340 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/pages/ApiDetail.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useEffect, useState } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { Card, Alert, Row, Col, Tabs } from "antd"; 4 | import { Layout } from "../components/Layout"; 5 | import { ProductHeader } from "../components/ProductHeader"; 6 | import { SwaggerUIWrapper } from "../components/SwaggerUIWrapper"; 7 | import api from "../lib/api"; 8 | import type { Product, ApiResponse } from "../types"; 9 | import ReactMarkdown from "react-markdown"; 10 | import remarkGfm from 'remark-gfm'; 11 | import 'react-markdown-editor-lite/lib/index.css'; 12 | import * as yaml from 'js-yaml'; 13 | import { Button, Typography, Space, Divider, message } from "antd"; 14 | import { CopyOutlined, RocketOutlined, DownloadOutlined } from "@ant-design/icons"; 15 | 16 | const { Title, Paragraph } = Typography; 17 | 18 | interface UpdatedProduct extends Omit<Product, 'apiSpec'> { 19 | apiConfig?: { 20 | spec: string; 21 | meta: { 22 | source: string; 23 | type: string; 24 | }; 25 | }; 26 | createAt: string; 27 | enabled: boolean; 28 | } 29 | 30 | function ApiDetailPage() { 31 | const { id } = useParams(); 32 | const [loading, setLoading] = useState(true); 33 | const [error, setError] = useState(''); 34 | const [apiData, setApiData] = useState<UpdatedProduct | null>(null); 35 | const [baseUrl, setBaseUrl] = useState<string>(''); 36 | const [examplePath, setExamplePath] = useState<string>('/{path}'); 37 | const [exampleMethod, setExampleMethod] = useState<string>('GET'); 38 | 39 | useEffect(() => { 40 | if (!id) return; 41 | fetchApiDetail(); 42 | }, [id]); 43 | 44 | const fetchApiDetail = async () => { 45 | setLoading(true); 46 | setError(''); 47 | try { 48 | const response: ApiResponse<UpdatedProduct> = await api.get(`/products/${id}`); 49 | if (response.code === "SUCCESS" && response.data) { 50 | setApiData(response.data); 51 | 52 | // 提取基础URL和示例路径用于curl示例 53 | if (response.data.apiConfig?.spec) { 54 | try { 55 | let openApiDoc: any; 56 | try { 57 | openApiDoc = yaml.load(response.data.apiConfig.spec); 58 | } catch { 59 | openApiDoc = JSON.parse(response.data.apiConfig.spec); 60 | } 61 | 62 | // 提取服务器URL并处理尾部斜杠 63 | let serverUrl = openApiDoc?.servers?.[0]?.url || ''; 64 | if (serverUrl && serverUrl.endsWith('/')) { 65 | serverUrl = serverUrl.slice(0, -1); // 移除末尾的斜杠 66 | } 67 | setBaseUrl(serverUrl); 68 | 69 | // 提取第一个可用的路径和方法作为示例 70 | const paths = openApiDoc?.paths; 71 | if (paths && typeof paths === 'object') { 72 | const pathEntries = Object.entries(paths); 73 | if (pathEntries.length > 0) { 74 | const [firstPath, pathMethods] = pathEntries[0] as [string, any]; 75 | if (pathMethods && typeof pathMethods === 'object') { 76 | const methods = Object.keys(pathMethods); 77 | if (methods.length > 0) { 78 | const firstMethod = methods[0].toUpperCase(); 79 | setExamplePath(firstPath); 80 | setExampleMethod(firstMethod); 81 | } 82 | } 83 | } 84 | } 85 | } catch (error) { 86 | console.error('解析OpenAPI规范失败:', error); 87 | } 88 | } 89 | } 90 | } catch (error) { 91 | console.error('获取API详情失败:', error); 92 | setError('加载失败,请稍后重试'); 93 | } finally { 94 | setLoading(false); 95 | } 96 | }; 97 | 98 | 99 | 100 | 101 | if (error) { 102 | return ( 103 | <Layout loading={loading}> 104 | <Alert message={error} type="error" showIcon className="my-8" /> 105 | </Layout> 106 | ); 107 | } 108 | 109 | if (!apiData) { 110 | return ( 111 | <Layout loading={loading}> 112 | <Alert message="未找到API信息" type="warning" showIcon className="my-8" /> 113 | </Layout> 114 | ); 115 | } 116 | 117 | return ( 118 | <Layout loading={loading}> 119 | <div className="mb-6"> 120 | <ProductHeader 121 | name={apiData.name} 122 | description={apiData.description} 123 | icon={apiData.icon} 124 | defaultIcon="/logo.svg" 125 | updatedAt={apiData.updatedAt} 126 | productType="REST_API" 127 | /> 128 | <hr className="border-gray-200 mt-4" /> 129 | </div> 130 | 131 | {/* 主要内容区域 - 左右布局 */} 132 | <Row gutter={24}> 133 | {/* 左侧内容 */} 134 | <Col span={15}> 135 | <Card className="mb-6 rounded-lg border-gray-200"> 136 | <Tabs 137 | defaultActiveKey="overview" 138 | items={[ 139 | { 140 | key: "overview", 141 | label: "Overview", 142 | children: apiData.document ? ( 143 | <div className="min-h-[400px]"> 144 | <div 145 | className="prose prose-lg max-w-none" 146 | style={{ 147 | lineHeight: '1.7', 148 | color: '#374151', 149 | fontSize: '16px', 150 | fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' 151 | }} 152 | > 153 | <style>{` 154 | .prose h1 { 155 | color: #111827; 156 | font-weight: 700; 157 | font-size: 2.25rem; 158 | line-height: 1.2; 159 | margin-top: 0; 160 | margin-bottom: 1.5rem; 161 | border-bottom: 2px solid #e5e7eb; 162 | padding-bottom: 0.5rem; 163 | } 164 | .prose h2 { 165 | color: #1f2937; 166 | font-weight: 600; 167 | font-size: 1.875rem; 168 | line-height: 1.3; 169 | margin-top: 2rem; 170 | margin-bottom: 1rem; 171 | border-bottom: 1px solid #e5e7eb; 172 | padding-bottom: 0.25rem; 173 | } 174 | .prose h3 { 175 | color: #374151; 176 | font-weight: 600; 177 | font-size: 1.5rem; 178 | margin-top: 1.5rem; 179 | margin-bottom: 0.75rem; 180 | } 181 | .prose p { 182 | margin-bottom: 1.25rem; 183 | color: #4b5563; 184 | line-height: 1.7; 185 | font-size: 16px; 186 | } 187 | .prose code { 188 | background-color: #f3f4f6; 189 | border: 1px solid #e5e7eb; 190 | border-radius: 0.375rem; 191 | padding: 0.125rem 0.375rem; 192 | font-size: 0.875rem; 193 | color: #374151; 194 | font-weight: 500; 195 | } 196 | .prose pre { 197 | background-color: #1f2937; 198 | border-radius: 0.5rem; 199 | padding: 1.25rem; 200 | overflow-x: auto; 201 | margin: 1.5rem 0; 202 | border: 1px solid #374151; 203 | } 204 | .prose pre code { 205 | background-color: transparent; 206 | border: none; 207 | color: #f9fafb; 208 | padding: 0; 209 | font-size: 0.875rem; 210 | font-weight: normal; 211 | } 212 | .prose blockquote { 213 | border-left: 4px solid #3b82f6; 214 | padding-left: 1rem; 215 | margin: 1.5rem 0; 216 | color: #6b7280; 217 | font-style: italic; 218 | background-color: #f8fafc; 219 | padding: 1rem; 220 | border-radius: 0.375rem; 221 | font-size: 16px; 222 | } 223 | .prose ul, .prose ol { 224 | margin: 1.25rem 0; 225 | padding-left: 1.5rem; 226 | } 227 | .prose ol { 228 | list-style-type: decimal; 229 | list-style-position: outside; 230 | } 231 | .prose ul { 232 | list-style-type: disc; 233 | list-style-position: outside; 234 | } 235 | .prose li { 236 | margin: 0.5rem 0; 237 | color: #4b5563; 238 | display: list-item; 239 | font-size: 16px; 240 | } 241 | .prose ol li { 242 | padding-left: 0.25rem; 243 | } 244 | .prose ul li { 245 | padding-left: 0.25rem; 246 | } 247 | .prose table { 248 | width: 100%; 249 | border-collapse: collapse; 250 | margin: 1.5rem 0; 251 | font-size: 16px; 252 | } 253 | .prose th, .prose td { 254 | border: 1px solid #d1d5db; 255 | padding: 0.75rem; 256 | text-align: left; 257 | } 258 | .prose th { 259 | background-color: #f9fafb; 260 | font-weight: 600; 261 | color: #374151; 262 | font-size: 16px; 263 | } 264 | .prose td { 265 | color: #4b5563; 266 | font-size: 16px; 267 | } 268 | .prose a { 269 | color: #3b82f6; 270 | text-decoration: underline; 271 | font-weight: 500; 272 | transition: color 0.2s; 273 | font-size: inherit; 274 | } 275 | .prose a:hover { 276 | color: #1d4ed8; 277 | } 278 | .prose strong { 279 | color: #111827; 280 | font-weight: 600; 281 | font-size: inherit; 282 | } 283 | .prose em { 284 | color: #6b7280; 285 | font-style: italic; 286 | font-size: inherit; 287 | } 288 | .prose hr { 289 | border: none; 290 | height: 1px; 291 | background-color: #e5e7eb; 292 | margin: 2rem 0; 293 | } 294 | `}</style> 295 | <ReactMarkdown remarkPlugins={[remarkGfm]}>{apiData.document}</ReactMarkdown> 296 | </div> 297 | </div> 298 | ) : ( 299 | <div className="text-gray-500 text-center py-8"> 300 | 暂无文档内容 301 | </div> 302 | ), 303 | }, 304 | { 305 | key: "openapi-spec", 306 | label: "OpenAPI Specification", 307 | children: ( 308 | <div> 309 | {apiData.apiConfig && apiData.apiConfig.spec ? ( 310 | <SwaggerUIWrapper apiSpec={apiData.apiConfig.spec} /> 311 | ) : ( 312 | <div className="text-gray-500 text-center py-8"> 313 | 暂无OpenAPI规范 314 | </div> 315 | )} 316 | </div> 317 | ), 318 | }, 319 | ]} 320 | /> 321 | </Card> 322 | </Col> 323 | 324 | {/* 右侧内容 */} 325 | <Col span={9}> 326 | <Card 327 | className="rounded-lg border-gray-200" 328 | title={ 329 | <Space> 330 | <RocketOutlined /> 331 | <span>快速开始</span> 332 | </Space> 333 | }> 334 | <Space direction="vertical" className="w-full" size="middle"> 335 | {/* cURL示例 */} 336 | <div> 337 | <Title level={5}>cURL调用示例</Title> 338 | <div className="bg-gray-50 p-3 rounded border relative"> 339 | <pre className="text-sm mb-0"> 340 | {`curl -X ${exampleMethod} \\ 341 | '${baseUrl || 'https://api.example.com'}${examplePath}' \\ 342 | -H 'Accept: application/json' \\ 343 | -H 'Content-Type: application/json'`} 344 | </pre> 345 | <Button 346 | type="text" 347 | size="small" 348 | icon={<CopyOutlined />} 349 | className="absolute top-2 right-2" 350 | onClick={() => { 351 | const curlCommand = `curl -X ${exampleMethod} \\\n '${baseUrl || 'https://api.example.com'}${examplePath}' \\\n -H 'Accept: application/json' \\\n -H 'Content-Type: application/json'`; 352 | navigator.clipboard.writeText(curlCommand); 353 | message.success('cURL命令已复制到剪贴板', 1); 354 | }} 355 | /> 356 | </div> 357 | </div> 358 | 359 | <Divider /> 360 | 361 | {/* 下载OAS文件 */} 362 | <div> 363 | <Title level={5}>OpenAPI规范文件</Title> 364 | <Paragraph type="secondary"> 365 | 下载完整的OpenAPI规范文件,用于代码生成、API测试等场景 366 | </Paragraph> 367 | <Space> 368 | <Button 369 | type="primary" 370 | icon={<DownloadOutlined />} 371 | onClick={() => { 372 | if (apiData?.apiConfig?.spec) { 373 | const blob = new Blob([apiData.apiConfig.spec], { type: 'text/yaml' }); 374 | const url = URL.createObjectURL(blob); 375 | const link = document.createElement('a'); 376 | link.href = url; 377 | link.download = `${apiData.name || 'api'}-openapi.yaml`; 378 | document.body.appendChild(link); 379 | link.click(); 380 | document.body.removeChild(link); 381 | URL.revokeObjectURL(url); 382 | message.success('OpenAPI规范文件下载成功', 1); 383 | } 384 | }} 385 | > 386 | 下载YAML 387 | </Button> 388 | <Button 389 | icon={<DownloadOutlined />} 390 | onClick={() => { 391 | if (apiData?.apiConfig?.spec) { 392 | try { 393 | const yamlDoc = yaml.load(apiData.apiConfig.spec); 394 | const jsonSpec = JSON.stringify(yamlDoc, null, 2); 395 | const blob = new Blob([jsonSpec], { type: 'application/json' }); 396 | const url = URL.createObjectURL(blob); 397 | const link = document.createElement('a'); 398 | link.href = url; 399 | link.download = `${apiData.name || 'api'}-openapi.json`; 400 | document.body.appendChild(link); 401 | link.click(); 402 | document.body.removeChild(link); 403 | URL.revokeObjectURL(url); 404 | message.success('OpenAPI规范文件下载成功', 1); 405 | } catch (error) { 406 | message.error('转换JSON格式失败'); 407 | } 408 | } 409 | }} 410 | > 411 | 下载JSON 412 | </Button> 413 | </Space> 414 | </div> 415 | </Space> 416 | </Card> 417 | </Col> 418 | </Row> 419 | 420 | </Layout> 421 | ); 422 | } 423 | 424 | export default ApiDetailPage; ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/pages/ApiProducts.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { memo, useCallback, useEffect, useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import type { MenuProps } from 'antd'; 4 | import { Badge, Button, Card, Dropdown, Modal, message, Pagination, Skeleton, Input, Select, Tag, Space } from 'antd'; 5 | import type { ApiProduct, ProductIcon } from '@/types/api-product'; 6 | import { ApiOutlined, MoreOutlined, PlusOutlined, ExclamationCircleOutlined, ExclamationCircleFilled, ClockCircleFilled, CheckCircleFilled, SearchOutlined } from '@ant-design/icons'; 7 | import McpServerIcon from '@/components/icons/McpServerIcon'; 8 | import { apiProductApi } from '@/lib/api'; 9 | import ApiProductFormModal from '@/components/api-product/ApiProductFormModal'; 10 | 11 | // 优化的产品卡片组件 12 | const ProductCard = memo(({ product, onNavigate, handleRefresh, onEdit }: { 13 | product: ApiProduct; 14 | onNavigate: (productId: string) => void; 15 | handleRefresh: () => void; 16 | onEdit: (product: ApiProduct) => void; 17 | }) => { 18 | // 处理产品图标的函数 19 | const getTypeIcon = (icon: ProductIcon | null | undefined, type: string) => { 20 | if (icon) { 21 | switch (icon.type) { 22 | case "URL": 23 | return <img src={icon.value} alt="icon" style={{ borderRadius: '8px', minHeight: '40px', width: '40px', height: '40px', objectFit: 'cover' }} /> 24 | case "BASE64": 25 | // 如果value已经包含data URL前缀,直接使用;否则添加前缀 26 | const src = icon.value.startsWith('data:') ? icon.value : `data:image/png;base64,${icon.value}`; 27 | return <img src={src} alt="icon" style={{ borderRadius: '8px', minHeight: '40px', width: '40px', height: '40px', objectFit: 'cover' }} /> 28 | default: 29 | return type === "REST_API" ? <ApiOutlined style={{ fontSize: '16px', width: '16px', height: '16px' }} /> : <McpServerIcon style={{ fontSize: '16px', width: '16px', height: '16px' }} /> 30 | } 31 | } else { 32 | return type === "REST_API" ? <ApiOutlined style={{ fontSize: '16px', width: '16px', height: '16px' }} /> : <McpServerIcon style={{ fontSize: '16px', width: '16px', height: '16px' }} /> 33 | } 34 | } 35 | 36 | const handleClick = useCallback(() => { 37 | onNavigate(product.productId) 38 | }, [product.productId, onNavigate]); 39 | 40 | const handleDelete = useCallback((productId: string, productName: string, e?: React.MouseEvent | any) => { 41 | if (e && e.stopPropagation) e.stopPropagation(); 42 | Modal.confirm({ 43 | title: '确认删除', 44 | icon: <ExclamationCircleOutlined />, 45 | content: `确定要删除API产品 "${productName}" 吗?此操作不可恢复。`, 46 | okText: '确认删除', 47 | okType: 'danger', 48 | cancelText: '取消', 49 | onOk() { 50 | apiProductApi.deleteApiProduct(productId).then(() => { 51 | message.success('API Product 删除成功'); 52 | handleRefresh(); 53 | }); 54 | }, 55 | }); 56 | }, [handleRefresh]); 57 | 58 | const handleEdit = useCallback((e?: React.MouseEvent | any) => { 59 | if (e && e?.domEvent?.stopPropagation) e.domEvent.stopPropagation(); 60 | onEdit(product); 61 | }, [product, onEdit]); 62 | 63 | const dropdownItems: MenuProps['items'] = [ 64 | { 65 | key: 'edit', 66 | label: '编辑', 67 | onClick: handleEdit, 68 | }, 69 | { 70 | type: 'divider', 71 | }, 72 | { 73 | key: 'delete', 74 | label: '删除', 75 | danger: true, 76 | onClick: (info: any) => handleDelete(product.productId, product.name, info?.domEvent), 77 | }, 78 | ] 79 | 80 | return ( 81 | <Card 82 | className="hover:shadow-lg transition-shadow cursor-pointer rounded-xl border border-gray-200 shadow-sm hover:border-blue-300" 83 | onClick={handleClick} 84 | bodyStyle={{ padding: '16px' }} 85 | > 86 | <div className="flex items-center justify-between mb-4"> 87 | <div className="flex items-center space-x-3"> 88 | <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100"> 89 | {getTypeIcon(product.icon, product.type)} 90 | </div> 91 | <div> 92 | <h3 className="text-lg font-semibold">{product.name}</h3> 93 | <div className="flex items-center gap-3 mt-1 flex-wrap"> 94 | {product.category && <Badge color="green" text={product.category} />} 95 | <div className="flex items-center"> 96 | {product.type === "REST_API" ? ( 97 | <ApiOutlined className="text-blue-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> 98 | ) : ( 99 | <McpServerIcon className="text-black mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> 100 | )} 101 | <span className="text-xs text-gray-700"> 102 | {product.type === "REST_API" ? "REST API" : "MCP Server"} 103 | </span> 104 | </div> 105 | <div className="flex items-center"> 106 | {product.status === "PENDING" ? ( 107 | <ExclamationCircleFilled className="text-yellow-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> 108 | ) : product.status === "READY" ? ( 109 | <ClockCircleFilled className="text-blue-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> 110 | ) : ( 111 | <CheckCircleFilled className="text-green-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> 112 | )} 113 | <span className="text-xs text-gray-700"> 114 | {product.status === "PENDING" ? "待配置" : product.status === "READY" ? "待发布" : "已发布"} 115 | </span> 116 | </div> 117 | </div> 118 | </div> 119 | </div> 120 | <Dropdown menu={{ items: dropdownItems }} trigger={['click']}> 121 | <Button 122 | type="text" 123 | icon={<MoreOutlined />} 124 | onClick={(e) => e.stopPropagation()} 125 | /> 126 | </Dropdown> 127 | </div> 128 | 129 | <div className="space-y-4"> 130 | {product.description && ( 131 | <p className="text-sm text-gray-600">{product.description}</p> 132 | )} 133 | 134 | </div> 135 | </Card> 136 | ) 137 | }) 138 | 139 | ProductCard.displayName = 'ProductCard' 140 | 141 | export default function ApiProducts() { 142 | const navigate = useNavigate(); 143 | const [apiProducts, setApiProducts] = useState<ApiProduct[]>([]); 144 | const [filters, setFilters] = useState<{ type?: string, name?: string }>({}); 145 | const [loading, setLoading] = useState(true); // 初始状态为 loading 146 | const [pagination, setPagination] = useState({ 147 | current: 1, 148 | pageSize: 12, 149 | total: 0, 150 | }); 151 | 152 | const [modalVisible, setModalVisible] = useState(false); 153 | const [editingProduct, setEditingProduct] = useState<ApiProduct | null>(null); 154 | 155 | // 搜索状态 156 | const [searchValue, setSearchValue] = useState(''); 157 | const [searchType, setSearchType] = useState<'name' | 'type'>('name'); 158 | const [activeFilters, setActiveFilters] = useState<Array<{ type: string; value: string; label: string }>>([]); 159 | 160 | const fetchApiProducts = useCallback((page = 1, size = 12, queryFilters?: { type?: string, name?: string }) => { 161 | setLoading(true); 162 | const params = { page, size, ...(queryFilters || {}) }; 163 | apiProductApi.getApiProducts(params).then((res: any) => { 164 | const products = res.data.content; 165 | setApiProducts(products); 166 | setPagination({ 167 | current: page, 168 | pageSize: size, 169 | total: res.data.totalElements || 0, 170 | }); 171 | }).finally(() => { 172 | setLoading(false); 173 | }); 174 | }, []); // 不依赖任何状态,避免无限循环 175 | 176 | useEffect(() => { 177 | fetchApiProducts(1, 12); 178 | }, []); // 只在组件初始化时执行一次 179 | 180 | // 产品类型选项 181 | const typeOptions = [ 182 | { label: 'REST API', value: 'REST_API' }, 183 | { label: 'MCP Server', value: 'MCP_SERVER' }, 184 | ]; 185 | 186 | // 搜索类型选项 187 | const searchTypeOptions = [ 188 | { label: '产品名称', value: 'name' as const }, 189 | { label: '产品类型', value: 'type' as const }, 190 | ]; 191 | 192 | // 搜索处理函数 193 | const handleSearch = () => { 194 | if (searchValue.trim()) { 195 | let labelText = ''; 196 | let filterValue = searchValue.trim(); 197 | 198 | if (searchType === 'name') { 199 | labelText = `产品名称:${searchValue.trim()}`; 200 | } else { 201 | const typeLabel = typeOptions.find(opt => opt.value === searchValue.trim())?.label || searchValue.trim(); 202 | labelText = `产品类型:${typeLabel}`; 203 | } 204 | 205 | const newFilter = { type: searchType, value: filterValue, label: labelText }; 206 | const updatedFilters = activeFilters.filter(f => f.type !== searchType); 207 | updatedFilters.push(newFilter); 208 | setActiveFilters(updatedFilters); 209 | 210 | const filters: { type?: string, name?: string } = {}; 211 | updatedFilters.forEach(filter => { 212 | if (filter.type === 'type' || filter.type === 'name') { 213 | filters[filter.type] = filter.value; 214 | } 215 | }); 216 | 217 | setFilters(filters); 218 | fetchApiProducts(1, pagination.pageSize, filters); 219 | setSearchValue(''); 220 | } 221 | }; 222 | 223 | // 移除单个筛选条件 224 | const removeFilter = (filterType: string) => { 225 | const updatedFilters = activeFilters.filter(f => f.type !== filterType); 226 | setActiveFilters(updatedFilters); 227 | 228 | const newFilters: { type?: string, name?: string } = {}; 229 | updatedFilters.forEach(filter => { 230 | if (filter.type === 'type' || filter.type === 'name') { 231 | newFilters[filter.type] = filter.value; 232 | } 233 | }); 234 | 235 | setFilters(newFilters); 236 | fetchApiProducts(1, pagination.pageSize, newFilters); 237 | }; 238 | 239 | // 清空所有筛选条件 240 | const clearAllFilters = () => { 241 | setActiveFilters([]); 242 | setFilters({}); 243 | fetchApiProducts(1, pagination.pageSize, {}); 244 | }; 245 | 246 | // 处理分页变化 247 | const handlePaginationChange = (page: number, pageSize: number) => { 248 | fetchApiProducts(page, pageSize, filters); // 传递当前filters 249 | }; 250 | 251 | // 直接使用服务端返回的列表 252 | 253 | // 优化的导航处理函数 254 | const handleNavigateToProduct = useCallback((productId: string) => { 255 | navigate(`/api-products/detail?productId=${productId}`); 256 | }, [navigate]); 257 | 258 | // 处理创建 259 | const handleCreate = () => { 260 | setEditingProduct(null); 261 | setModalVisible(true); 262 | }; 263 | 264 | // 处理编辑 265 | const handleEdit = (product: ApiProduct) => { 266 | setEditingProduct(product); 267 | setModalVisible(true); 268 | }; 269 | 270 | // 处理模态框成功 271 | const handleModalSuccess = () => { 272 | setModalVisible(false); 273 | setEditingProduct(null); 274 | fetchApiProducts(pagination.current, pagination.pageSize, filters); 275 | }; 276 | 277 | // 处理模态框取消 278 | const handleModalCancel = () => { 279 | setModalVisible(false); 280 | setEditingProduct(null); 281 | }; 282 | 283 | return ( 284 | <div className="space-y-6"> 285 | <div className="flex items-center justify-between"> 286 | <div> 287 | <h1 className="text-3xl font-bold tracking-tight">API Products</h1> 288 | <p className="text-gray-500 mt-2"> 289 | 管理和配置您的API产品 290 | </p> 291 | </div> 292 | <Button onClick={handleCreate} type="primary" icon={<PlusOutlined/>}> 293 | 创建 API Product 294 | </Button> 295 | </div> 296 | 297 | {/* 搜索和筛选 */} 298 | <div className="space-y-4"> 299 | {/* 搜索框 */} 300 | <div className="flex items-center max-w-xl"> 301 | {/* 左侧:搜索类型选择器 */} 302 | <Select 303 | value={searchType} 304 | onChange={setSearchType} 305 | style={{ 306 | width: 120, 307 | borderTopRightRadius: 0, 308 | borderBottomRightRadius: 0, 309 | backgroundColor: '#f5f5f5', 310 | }} 311 | className="h-10" 312 | size="large" 313 | > 314 | {searchTypeOptions.map(option => ( 315 | <Select.Option key={option.value} value={option.value}> 316 | {option.label} 317 | </Select.Option> 318 | ))} 319 | </Select> 320 | 321 | {/* 中间:搜索值输入框或选择框 */} 322 | {searchType === 'type' ? ( 323 | <Select 324 | placeholder="请选择产品类型" 325 | value={searchValue} 326 | onChange={(value) => { 327 | setSearchValue(value); 328 | // 对于类型选择,立即执行搜索 329 | if (value) { 330 | const typeLabel = typeOptions.find(opt => opt.value === value)?.label || value; 331 | const labelText = `产品类型:${typeLabel}`; 332 | const newFilter = { type: 'type', value, label: labelText }; 333 | const updatedFilters = activeFilters.filter(f => f.type !== 'type'); 334 | updatedFilters.push(newFilter); 335 | setActiveFilters(updatedFilters); 336 | 337 | const filters: { type?: string, name?: string } = {}; 338 | updatedFilters.forEach(filter => { 339 | if (filter.type === 'type' || filter.type === 'name') { 340 | filters[filter.type] = filter.value; 341 | } 342 | }); 343 | 344 | setFilters(filters); 345 | fetchApiProducts(1, pagination.pageSize, filters); 346 | setSearchValue(''); 347 | } 348 | }} 349 | style={{ 350 | flex: 1, 351 | borderTopLeftRadius: 0, 352 | borderBottomLeftRadius: 0, 353 | borderTopRightRadius: 0, 354 | borderBottomRightRadius: 0, 355 | }} 356 | allowClear 357 | onClear={clearAllFilters} 358 | className="h-10" 359 | size="large" 360 | > 361 | {typeOptions.map(option => ( 362 | <Select.Option key={option.value} value={option.value}> 363 | {option.label} 364 | </Select.Option> 365 | ))} 366 | </Select> 367 | ) : ( 368 | <Input 369 | placeholder="请输入要检索的产品名称" 370 | value={searchValue} 371 | onChange={(e) => setSearchValue(e.target.value)} 372 | style={{ 373 | flex: 1, 374 | borderTopLeftRadius: 0, 375 | borderBottomLeftRadius: 0, 376 | borderTopRightRadius: 0, 377 | borderBottomRightRadius: 0, 378 | }} 379 | onPressEnter={handleSearch} 380 | allowClear 381 | onClear={() => setSearchValue('')} 382 | size="large" 383 | className="h-10" 384 | /> 385 | )} 386 | 387 | {/* 右侧:搜索按钮 */} 388 | <Button 389 | icon={<SearchOutlined />} 390 | onClick={handleSearch} 391 | style={{ 392 | borderTopLeftRadius: 0, 393 | borderBottomLeftRadius: 0, 394 | width: 48, 395 | }} 396 | className="h-10" 397 | size="large" 398 | /> 399 | </div> 400 | 401 | {/* 筛选条件标签 */} 402 | {activeFilters.length > 0 && ( 403 | <div className="flex items-center gap-2"> 404 | <span className="text-sm text-gray-500">筛选条件:</span> 405 | <Space wrap> 406 | {activeFilters.map(filter => ( 407 | <Tag 408 | key={filter.type} 409 | closable 410 | onClose={() => removeFilter(filter.type)} 411 | style={{ 412 | backgroundColor: '#f5f5f5', 413 | border: '1px solid #d9d9d9', 414 | borderRadius: '16px', 415 | color: '#666', 416 | fontSize: '12px', 417 | padding: '4px 12px', 418 | }} 419 | > 420 | {filter.label} 421 | </Tag> 422 | ))} 423 | </Space> 424 | <Button 425 | type="link" 426 | size="small" 427 | onClick={clearAllFilters} 428 | className="text-blue-500 hover:text-blue-600 text-sm" 429 | > 430 | 清除筛选条件 431 | </Button> 432 | </div> 433 | )} 434 | </div> 435 | 436 | {loading ? ( 437 | <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> 438 | {Array.from({ length: pagination.pageSize || 12 }).map((_, index) => ( 439 | <div key={index} className="h-full rounded-lg shadow-lg bg-white p-4"> 440 | <div className="flex items-start space-x-4"> 441 | <Skeleton.Avatar size={48} active /> 442 | <div className="flex-1 min-w-0"> 443 | <div className="flex items-center justify-between mb-2"> 444 | <Skeleton.Input active size="small" style={{ width: 120 }} /> 445 | <Skeleton.Input active size="small" style={{ width: 60 }} /> 446 | </div> 447 | <Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} /> 448 | <Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} /> 449 | <div className="flex items-center justify-between"> 450 | <Skeleton.Input active size="small" style={{ width: 60 }} /> 451 | <Skeleton.Input active size="small" style={{ width: 80 }} /> 452 | </div> 453 | </div> 454 | </div> 455 | </div> 456 | ))} 457 | </div> 458 | ) : ( 459 | <> 460 | <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> 461 | {apiProducts.map((product) => ( 462 | <ProductCard 463 | key={product.productId} 464 | product={product} 465 | onNavigate={handleNavigateToProduct} 466 | handleRefresh={() => fetchApiProducts(pagination.current, pagination.pageSize, filters)} 467 | onEdit={handleEdit} 468 | /> 469 | ))} 470 | </div> 471 | 472 | {pagination.total > 0 && ( 473 | <div className="flex justify-center mt-6"> 474 | <Pagination 475 | current={pagination.current} 476 | pageSize={pagination.pageSize} 477 | total={pagination.total} 478 | onChange={handlePaginationChange} 479 | showSizeChanger 480 | showQuickJumper 481 | showTotal={(total) => `共 ${total} 条`} 482 | pageSizeOptions={['6', '12', '24', '48']} 483 | /> 484 | </div> 485 | )} 486 | </> 487 | )} 488 | 489 | <ApiProductFormModal 490 | visible={modalVisible} 491 | onCancel={handleModalCancel} 492 | onSuccess={handleModalSuccess} 493 | productId={editingProduct?.productId} 494 | initialData={editingProduct || undefined} 495 | /> 496 | </div> 497 | ) 498 | } 499 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/AIGatewayOperator.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.gateway; 21 | 22 | import cn.hutool.core.codec.Base64; 23 | import cn.hutool.core.map.MapUtil; 24 | import cn.hutool.core.util.StrUtil; 25 | import cn.hutool.json.JSONArray; 26 | import cn.hutool.json.JSONObject; 27 | import cn.hutool.json.JSONUtil; 28 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 29 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 30 | import com.alibaba.apiopenplatform.dto.result.GatewayMCPServerResult; 31 | import com.alibaba.apiopenplatform.dto.result.*; 32 | import com.alibaba.apiopenplatform.entity.Gateway; 33 | import com.alibaba.apiopenplatform.service.gateway.client.APIGClient; 34 | import com.alibaba.apiopenplatform.service.gateway.client.PopGatewayClient; 35 | import com.alibaba.apiopenplatform.service.gateway.client.SLSClient; 36 | import com.alibaba.apiopenplatform.support.consumer.APIGAuthConfig; 37 | import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; 38 | import com.alibaba.apiopenplatform.support.enums.APIGAPIType; 39 | import com.alibaba.apiopenplatform.support.enums.GatewayType; 40 | import com.alibaba.apiopenplatform.support.product.APIGRefConfig; 41 | import com.aliyuncs.http.MethodType; 42 | import com.aliyun.sdk.gateway.pop.exception.PopClientException; 43 | import com.aliyun.sdk.service.apig20240327.models.*; 44 | import com.aliyun.sdk.service.sls20201230.models.*; 45 | import lombok.extern.slf4j.Slf4j; 46 | import org.springframework.stereotype.Service; 47 | 48 | import java.util.*; 49 | import java.util.concurrent.CompletableFuture; 50 | import java.util.concurrent.ExecutionException; 51 | import java.util.stream.Collectors; 52 | 53 | @Service 54 | @Slf4j 55 | public class AIGatewayOperator extends APIGOperator { 56 | 57 | @Override 58 | public PageResult<? extends GatewayMCPServerResult> fetchMcpServers(Gateway gateway, int page, int size) { 59 | PopGatewayClient client = new PopGatewayClient(gateway.getApigConfig()); 60 | 61 | Map<String , String> queryParams = MapUtil.<String, String>builder() 62 | .put("gatewayId", gateway.getGatewayId()) 63 | .put("pageNumber", String.valueOf(page)) 64 | .put("pageSize", String.valueOf(size)) 65 | .build(); 66 | 67 | return client.execute("/v1/mcp-servers", MethodType.GET, queryParams, data -> { 68 | List<APIGMCPServerResult> mcpServers = Optional.ofNullable(data.getJSONArray("items")) 69 | .map(items -> items.stream() 70 | .map(JSONObject.class::cast) 71 | .map(json -> { 72 | APIGMCPServerResult result = new APIGMCPServerResult(); 73 | result.setMcpServerName(json.getStr("name")); 74 | result.setMcpServerId(json.getStr("mcpServerId")); 75 | result.setMcpRouteId(json.getStr("routeId")); 76 | result.setApiId(json.getStr("apiId")); 77 | return result; 78 | }) 79 | .collect(Collectors.toList())) 80 | .orElse(new ArrayList<>()); 81 | 82 | return PageResult.of(mcpServers, page, size, data.getInt("totalSize")); 83 | }); 84 | } 85 | 86 | public PageResult<? extends GatewayMCPServerResult> fetchMcpServers_V1(Gateway gateway, int page, int size) { 87 | PageResult<APIResult> apiPage = fetchAPIs(gateway, APIGAPIType.MCP, 0, 1); 88 | if (apiPage.getTotalElements() == 0) { 89 | return PageResult.empty(page, size); 90 | } 91 | 92 | // MCP Server定义在一个API下 93 | String apiId = apiPage.getContent().get(0).getApiId(); 94 | try { 95 | PageResult<HttpRoute> routesPage = fetchHttpRoutes(gateway, apiId, page, size); 96 | if (routesPage.getTotalElements() == 0) { 97 | return PageResult.empty(page, size); 98 | } 99 | 100 | return PageResult.<APIGMCPServerResult>builder().build() 101 | .mapFrom(routesPage, route -> { 102 | APIGMCPServerResult r = new APIGMCPServerResult().convertFrom(route); 103 | r.setApiId(apiId); 104 | return r; 105 | }); 106 | } catch (Exception e) { 107 | log.error("Error fetching MCP servers", e); 108 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching MCP servers,Cause:" + e.getMessage()); 109 | } 110 | } 111 | 112 | @Override 113 | public String fetchMcpConfig(Gateway gateway, Object conf) { 114 | APIGRefConfig config = (APIGRefConfig) conf; 115 | PopGatewayClient client = new PopGatewayClient(gateway.getApigConfig()); 116 | String mcpServerId = config.getMcpServerId(); 117 | MCPConfigResult mcpConfig = new MCPConfigResult(); 118 | 119 | return client.execute("/v1/mcp-servers/" + mcpServerId, MethodType.GET, null, data -> { 120 | mcpConfig.setMcpServerName(data.getStr("name")); 121 | 122 | // mcpServer config 123 | MCPConfigResult.MCPServerConfig serverConfig = new MCPConfigResult.MCPServerConfig(); 124 | String path = data.getStr("mcpServerPath"); 125 | String exposedUriPath = data.getStr("exposedUriPath"); 126 | if (StrUtil.isNotBlank(exposedUriPath)) { 127 | path += exposedUriPath; 128 | } 129 | serverConfig.setPath(path); 130 | 131 | JSONArray domains = data.getJSONArray("domainInfos"); 132 | if (domains != null && !domains.isEmpty()) { 133 | serverConfig.setDomains(domains.stream() 134 | .map(JSONObject.class::cast) 135 | .map(json -> MCPConfigResult.Domain.builder() 136 | .domain(json.getStr("name")) 137 | .protocol(Optional.ofNullable(json.getStr("protocol")) 138 | .map(String::toLowerCase) 139 | .orElse(null)) 140 | .build()) 141 | .collect(Collectors.toList())); 142 | } 143 | mcpConfig.setMcpServerConfig(serverConfig); 144 | 145 | // meta 146 | MCPConfigResult.McpMetadata meta = new MCPConfigResult.McpMetadata(); 147 | meta.setSource(GatewayType.APIG_AI.name()); 148 | meta.setProtocol(data.getStr("protocol")); 149 | meta.setCreateFromType(data.getStr("createFromType")); 150 | mcpConfig.setMeta(meta); 151 | 152 | // tools 153 | String tools = data.getStr("mcpServerConfig"); 154 | if (StrUtil.isNotBlank(tools)) { 155 | mcpConfig.setTools(Base64.decodeStr(tools)); 156 | } 157 | 158 | return JSONUtil.toJsonStr(mcpConfig); 159 | }); 160 | } 161 | 162 | public String fetchMcpConfig_V1(Gateway gateway, Object conf) { 163 | APIGRefConfig config = (APIGRefConfig) conf; 164 | HttpRoute httpRoute = fetchHTTPRoute(gateway, config.getApiId(), config.getMcpRouteId()); 165 | 166 | MCPConfigResult m = new MCPConfigResult(); 167 | m.setMcpServerName(httpRoute.getName()); 168 | 169 | // mcpServer config 170 | MCPConfigResult.MCPServerConfig c = new MCPConfigResult.MCPServerConfig(); 171 | if (httpRoute.getMatch() != null) { 172 | c.setPath(httpRoute.getMatch().getPath().getValue()); 173 | } 174 | if (httpRoute.getDomainInfos() != null) { 175 | c.setDomains(httpRoute.getDomainInfos().stream() 176 | .map(domainInfo -> MCPConfigResult.Domain.builder() 177 | .domain(domainInfo.getName()) 178 | .protocol(Optional.ofNullable(domainInfo.getProtocol()) 179 | .map(String::toLowerCase) 180 | .orElse(null)) 181 | .build()) 182 | .collect(Collectors.toList())); 183 | } 184 | m.setMcpServerConfig(c); 185 | 186 | // meta 187 | MCPConfigResult.McpMetadata meta = new MCPConfigResult.McpMetadata(); 188 | meta.setSource(GatewayType.APIG_AI.name()); 189 | 190 | // tools 191 | HttpRoute.McpServerInfo mcpServerInfo = httpRoute.getMcpServerInfo(); 192 | boolean fetchTool = true; 193 | if (mcpServerInfo.getMcpRouteConfig() != null) { 194 | String protocol = mcpServerInfo.getMcpRouteConfig().getProtocol(); 195 | meta.setCreateFromType(protocol); 196 | 197 | // HTTP转MCP需从插件获取tools配置 198 | fetchTool = StrUtil.equalsIgnoreCase(protocol, "HTTP"); 199 | } 200 | 201 | if (fetchTool) { 202 | String toolSpec = fetchMcpTools(gateway, config.getMcpRouteId()); 203 | if (StrUtil.isNotBlank(toolSpec)) { 204 | m.setTools(toolSpec); 205 | // 默认为HTTP转MCP 206 | if (StrUtil.isBlank(meta.getCreateFromType())) { 207 | meta.setCreateFromType("HTTP"); 208 | } 209 | } 210 | } 211 | 212 | m.setMeta(meta); 213 | return JSONUtil.toJsonStr(m); 214 | } 215 | 216 | @Override 217 | public GatewayType getGatewayType() { 218 | return GatewayType.APIG_AI; 219 | } 220 | 221 | @Override 222 | public String getDashboard(Gateway gateway, String type) { 223 | SLSClient ticketClient = new SLSClient(gateway.getApigConfig(), true); 224 | String ticket = null; 225 | try { 226 | CreateTicketResponse response = ticketClient.execute(c -> { 227 | CreateTicketRequest request = CreateTicketRequest.builder().build(); 228 | try { 229 | return c.createTicket(request).get(); 230 | } catch (InterruptedException | ExecutionException e) { 231 | throw new RuntimeException(e); 232 | } 233 | }); 234 | ticket = response.getBody().getTicket(); 235 | } catch (Exception e) { 236 | log.error("Error fetching API", e); 237 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching createTicket API,Cause:" + e.getMessage()); 238 | } 239 | SLSClient client = new SLSClient(gateway.getApigConfig(), false); 240 | String projectName = null; 241 | try { 242 | ListProjectResponse response = client.execute(c -> { 243 | ListProjectRequest request = ListProjectRequest.builder().projectName("product").build(); 244 | try { 245 | return c.listProject(request).get(); 246 | } catch (InterruptedException | ExecutionException e) { 247 | throw new RuntimeException(e); 248 | } 249 | }); 250 | projectName = response.getBody().getProjects().get(0).getProjectName(); 251 | } catch (Exception e) { 252 | log.error("Error fetching Project", e); 253 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Project,Cause:" + e.getMessage()); 254 | } 255 | String region = gateway.getApigConfig().getRegion(); 256 | String gatewayId = gateway.getGatewayId(); 257 | String dashboardId = ""; 258 | String gatewayFilter = ""; 259 | if (type.equals("Portal")) { 260 | dashboardId = "dashboard-1758009692051-393998"; 261 | gatewayFilter = ""; 262 | } else if (type.equals("MCP")) { 263 | dashboardId = "dashboard-1757483808537-433375"; 264 | gatewayFilter = "filters=cluster_id%%253A%%2520" + gatewayId; 265 | } else if (type.equals("API")) { 266 | dashboardId = "dashboard-1756276497392-966932"; 267 | gatewayFilter = "filters=cluster_id%%253A%%2520" + gatewayId; 268 | ; 269 | } 270 | String dashboardUrl = String.format("https://sls.console.aliyun.com/lognext/project/%s/dashboard/%s?%s&slsRegion=%s&sls_ticket=%s&isShare=true&hideTopbar=true&hideSidebar=true&ignoreTabLocalStorage=true", projectName, dashboardId, gatewayFilter, region, ticket); 271 | log.info("Dashboard URL: {}", dashboardUrl); 272 | return dashboardUrl; 273 | } 274 | 275 | public String fetchMcpTools(Gateway gateway, String routeId) { 276 | APIGClient client = getClient(gateway); 277 | 278 | try { 279 | CompletableFuture<ListPluginAttachmentsResponse> f = client.execute(c -> { 280 | ListPluginAttachmentsRequest request = ListPluginAttachmentsRequest.builder() 281 | .gatewayId(gateway.getGatewayId()) 282 | .attachResourceId(routeId) 283 | .attachResourceType("GatewayRoute") 284 | .pageNumber(1) 285 | .pageSize(100) 286 | .build(); 287 | 288 | return c.listPluginAttachments(request); 289 | }); 290 | 291 | ListPluginAttachmentsResponse response = f.join(); 292 | if (response.getStatusCode() != 200) { 293 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 294 | } 295 | 296 | for (ListPluginAttachmentsResponseBody.Items item : response.getBody().getData().getItems()) { 297 | PluginClassInfo classInfo = item.getPluginClassInfo(); 298 | 299 | if (!StrUtil.equalsIgnoreCase(classInfo.getName(), "mcp-server")) { 300 | continue; 301 | } 302 | 303 | String pluginConfig = item.getPluginConfig(); 304 | if (StrUtil.isNotBlank(pluginConfig)) { 305 | return Base64.decodeStr(pluginConfig); 306 | } 307 | } 308 | } catch (Exception e) { 309 | log.error("Error fetching Plugin Attachment", e); 310 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Plugin Attachment,Cause:" + e.getMessage()); 311 | } 312 | return null; 313 | } 314 | 315 | @Override 316 | public ConsumerAuthConfig authorizeConsumer(Gateway gateway, String consumerId, Object refConfig) { 317 | APIGClient client = getClient(gateway); 318 | 319 | APIGRefConfig config = (APIGRefConfig) refConfig; 320 | // MCP Server 授权 321 | String mcpRouteId = config.getMcpRouteId(); 322 | 323 | try { 324 | // 确认Gateway的EnvId 325 | String envId = fetchGatewayEnv(gateway); 326 | 327 | CreateConsumerAuthorizationRulesRequest.AuthorizationRules rule = CreateConsumerAuthorizationRulesRequest.AuthorizationRules.builder() 328 | .consumerId(consumerId) 329 | .expireMode("LongTerm") 330 | .resourceType("MCP") 331 | .resourceIdentifier(CreateConsumerAuthorizationRulesRequest.ResourceIdentifier.builder() 332 | .resourceId(mcpRouteId) 333 | .environmentId(envId).build()) 334 | .build(); 335 | 336 | CompletableFuture<CreateConsumerAuthorizationRulesResponse> f = client.execute(c -> { 337 | CreateConsumerAuthorizationRulesRequest request = CreateConsumerAuthorizationRulesRequest.builder() 338 | .authorizationRules(Collections.singletonList(rule)) 339 | .build(); 340 | 341 | return c.createConsumerAuthorizationRules(request); 342 | } 343 | ); 344 | CreateConsumerAuthorizationRulesResponse response = f.join(); 345 | if (200 != response.getStatusCode()) { 346 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 347 | } 348 | 349 | APIGAuthConfig apigAuthConfig = APIGAuthConfig.builder() 350 | .authorizationRuleIds(response.getBody().getData().getConsumerAuthorizationRuleIds()) 351 | .build(); 352 | return ConsumerAuthConfig.builder() 353 | .apigAuthConfig(apigAuthConfig) 354 | .build(); 355 | } catch (Exception e) { 356 | Throwable cause = e.getCause(); 357 | if (cause instanceof PopClientException 358 | && "Conflict.ConsumerAuthorizationForbidden".equals(((PopClientException) cause).getErrCode())) { 359 | return getConsumerAuthorization(gateway, consumerId, mcpRouteId); 360 | } 361 | log.error("Error authorizing consumer {} to mcp server {} in AI gateway {}", consumerId, mcpRouteId, gateway.getGatewayId(), e); 362 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, "Failed to authorize consumer to mcp server in AI gateway: " + e.getMessage()); 363 | } 364 | } 365 | 366 | public ConsumerAuthConfig getConsumerAuthorization(Gateway gateway, String consumerId, String resourceId) { 367 | APIGClient client = getClient(gateway); 368 | 369 | CompletableFuture<QueryConsumerAuthorizationRulesResponse> f = client.execute(c -> { 370 | QueryConsumerAuthorizationRulesRequest request = QueryConsumerAuthorizationRulesRequest.builder() 371 | .consumerId(consumerId) 372 | .resourceId(resourceId) 373 | .resourceType("MCP") 374 | .build(); 375 | 376 | return c.queryConsumerAuthorizationRules(request); 377 | }); 378 | QueryConsumerAuthorizationRulesResponse response = f.join(); 379 | 380 | if (200 != response.getStatusCode()) { 381 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 382 | } 383 | 384 | QueryConsumerAuthorizationRulesResponseBody.Items items = response.getBody().getData().getItems().get(0); 385 | APIGAuthConfig apigAuthConfig = APIGAuthConfig.builder() 386 | .authorizationRuleIds(Collections.singletonList(items.getConsumerAuthorizationRuleId())) 387 | .build(); 388 | 389 | return ConsumerAuthConfig.builder() 390 | .apigAuthConfig(apigAuthConfig) 391 | .build(); 392 | } 393 | } 394 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/ProductServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.impl; 21 | 22 | import cn.hutool.core.collection.CollUtil; 23 | import cn.hutool.core.util.StrUtil; 24 | import cn.hutool.json.JSONUtil; 25 | import com.alibaba.apiopenplatform.core.constant.Resources; 26 | import com.alibaba.apiopenplatform.core.event.PortalDeletingEvent; 27 | import com.alibaba.apiopenplatform.core.event.ProductDeletingEvent; 28 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 29 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 30 | import com.alibaba.apiopenplatform.core.security.ContextHolder; 31 | import com.alibaba.apiopenplatform.core.utils.IdGenerator; 32 | import com.alibaba.apiopenplatform.dto.params.product.*; 33 | import com.alibaba.apiopenplatform.dto.result.*; 34 | import com.alibaba.apiopenplatform.entity.*; 35 | import com.alibaba.apiopenplatform.repository.*; 36 | import com.alibaba.apiopenplatform.service.GatewayService; 37 | import com.alibaba.apiopenplatform.service.PortalService; 38 | import com.alibaba.apiopenplatform.service.ProductService; 39 | import com.alibaba.apiopenplatform.service.NacosService; 40 | import com.alibaba.apiopenplatform.support.enums.ProductStatus; 41 | import com.alibaba.apiopenplatform.support.enums.ProductType; 42 | import com.alibaba.apiopenplatform.support.enums.SourceType; 43 | import com.alibaba.apiopenplatform.support.product.NacosRefConfig; 44 | import lombok.RequiredArgsConstructor; 45 | import lombok.extern.slf4j.Slf4j; 46 | import org.springframework.context.ApplicationEventPublisher; 47 | import org.springframework.context.event.EventListener; 48 | import org.springframework.data.domain.Page; 49 | 50 | import javax.persistence.criteria.*; 51 | import javax.transaction.Transactional; 52 | 53 | import org.springframework.data.domain.Pageable; 54 | import org.springframework.data.jpa.domain.Specification; 55 | import org.springframework.scheduling.annotation.Async; 56 | import org.springframework.stereotype.Service; 57 | 58 | import java.util.*; 59 | import java.util.stream.Collectors; 60 | 61 | @Service 62 | @Slf4j 63 | @RequiredArgsConstructor 64 | @Transactional 65 | public class ProductServiceImpl implements ProductService { 66 | 67 | private final ContextHolder contextHolder; 68 | 69 | private final PortalService portalService; 70 | 71 | private final GatewayService gatewayService; 72 | 73 | private final ProductRepository productRepository; 74 | 75 | private final ProductRefRepository productRefRepository; 76 | 77 | private final ProductPublicationRepository publicationRepository; 78 | 79 | private final SubscriptionRepository subscriptionRepository; 80 | 81 | private final ConsumerRepository consumerRepository; 82 | 83 | private final NacosService nacosService; 84 | 85 | private final ApplicationEventPublisher eventPublisher; 86 | 87 | @Override 88 | public ProductResult createProduct(CreateProductParam param) { 89 | productRepository.findByNameAndAdminId(param.getName(), contextHolder.getUser()) 90 | .ifPresent(product -> { 91 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PRODUCT, product.getName())); 92 | }); 93 | 94 | String productId = IdGenerator.genApiProductId(); 95 | 96 | Product product = param.convertTo(); 97 | product.setProductId(productId); 98 | product.setAdminId(contextHolder.getUser()); 99 | 100 | // 设置默认的自动审批配置,如果未指定则默认为null(使用平台级别配置) 101 | if (param.getAutoApprove() != null) { 102 | product.setAutoApprove(param.getAutoApprove()); 103 | } 104 | 105 | productRepository.save(product); 106 | 107 | return getProduct(productId); 108 | } 109 | 110 | @Override 111 | public ProductResult getProduct(String productId) { 112 | Product product = contextHolder.isAdministrator() ? 113 | findProduct(productId) : 114 | findPublishedProduct(contextHolder.getPortal(), productId); 115 | 116 | ProductResult result = new ProductResult().convertFrom(product); 117 | 118 | // 补充Product信息 119 | fullFillProduct(result); 120 | return result; 121 | } 122 | 123 | @Override 124 | public PageResult<ProductResult> listProducts(QueryProductParam param, Pageable pageable) { 125 | log.info("zhaoh-test-listProducts-start"); 126 | if (contextHolder.isDeveloper()) { 127 | param.setPortalId(contextHolder.getPortal()); 128 | } 129 | 130 | Page<Product> products = productRepository.findAll(buildSpecification(param), pageable); 131 | return new PageResult<ProductResult>().convertFrom( 132 | products, product -> { 133 | ProductResult result = new ProductResult().convertFrom(product); 134 | fullFillProduct(result); 135 | return result; 136 | }); 137 | } 138 | 139 | @Override 140 | public ProductResult updateProduct(String productId, UpdateProductParam param) { 141 | Product product = findProduct(productId); 142 | 143 | // 更换API产品类型 144 | if (param.getType() != null && product.getType() != param.getType()) { 145 | productRefRepository.findFirstByProductId(productId) 146 | .ifPresent(productRef -> { 147 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "API产品已关联API"); 148 | }); 149 | } 150 | param.update(product); 151 | 152 | // Consumer鉴权配置同步至网关 153 | Optional.ofNullable(param.getEnableConsumerAuth()).ifPresent(product::setEnableConsumerAuth); 154 | 155 | // 更新自动审批配置 156 | Optional.ofNullable(param.getAutoApprove()).ifPresent(product::setAutoApprove); 157 | 158 | productRepository.saveAndFlush(product); 159 | return getProduct(product.getProductId()); 160 | } 161 | 162 | @Override 163 | public void publishProduct(String productId, String portalId) { 164 | portalService.existsPortal(portalId); 165 | if (publicationRepository.findByPortalIdAndProductId(portalId, productId).isPresent()) { 166 | return; 167 | } 168 | 169 | Product product = findProduct(productId); 170 | product.setStatus(ProductStatus.PUBLISHED); 171 | 172 | // 未关联不允许发布 173 | if (getProductRef(productId) == null) { 174 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "API产品未关联API"); 175 | } 176 | 177 | ProductPublication productPublication = new ProductPublication(); 178 | productPublication.setPortalId(portalId); 179 | productPublication.setProductId(productId); 180 | 181 | publicationRepository.save(productPublication); 182 | productRepository.save(product); 183 | } 184 | 185 | @Override 186 | public PageResult<ProductPublicationResult> getPublications(String productId, Pageable pageable) { 187 | Page<ProductPublication> publications = publicationRepository.findByProductId(productId, pageable); 188 | 189 | return new PageResult<ProductPublicationResult>().convertFrom( 190 | publications, publication -> { 191 | ProductPublicationResult publicationResult = new ProductPublicationResult().convertFrom(publication); 192 | PortalResult portal; 193 | try { 194 | portal = portalService.getPortal(publication.getPortalId()); 195 | } catch (Exception e) { 196 | log.error("Failed to get portal: {}", publication.getPortalId(), e); 197 | return null; 198 | } 199 | 200 | publicationResult.setPortalName(portal.getName()); 201 | publicationResult.setAutoApproveSubscriptions(portal.getPortalSettingConfig().getAutoApproveSubscriptions()); 202 | 203 | return publicationResult; 204 | }); 205 | } 206 | 207 | @Override 208 | public void unpublishProduct(String productId, String portalId) { 209 | portalService.existsPortal(portalId); 210 | 211 | publicationRepository.findByPortalIdAndProductId(portalId, productId) 212 | .ifPresent(publicationRepository::delete); 213 | } 214 | 215 | @Override 216 | public void deleteProduct(String productId) { 217 | Product Product = findProduct(productId); 218 | 219 | // 下线后删除 220 | publicationRepository.deleteByProductId(productId); 221 | productRepository.delete(Product); 222 | 223 | // 异步清理Product资源 224 | eventPublisher.publishEvent(new ProductDeletingEvent(productId)); 225 | } 226 | 227 | /** 228 | * 查找产品,如果不存在则抛出异常 229 | */ 230 | private Product findProduct(String productId) { 231 | return productRepository.findByProductId(productId) 232 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, productId)); 233 | } 234 | 235 | @Override 236 | public void addProductRef(String productId, CreateProductRefParam param) { 237 | Product product = findProduct(productId); 238 | 239 | // 是否已存在API引用 240 | productRefRepository.findByProductId(product.getProductId()) 241 | .ifPresent(productRef -> { 242 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已关联API", Resources.PRODUCT, productId)); 243 | }); 244 | ProductRef productRef = param.convertTo(); 245 | productRef.setProductId(productId); 246 | syncConfig(product, productRef); 247 | 248 | productRepository.save(product); 249 | productRefRepository.save(productRef); 250 | } 251 | 252 | @Override 253 | public ProductRefResult getProductRef(String productId) { 254 | return productRefRepository.findFirstByProductId(productId) 255 | .map(productRef -> new ProductRefResult().convertFrom(productRef)) 256 | .orElse(null); 257 | } 258 | 259 | @Override 260 | public void deleteProductRef(String productId) { 261 | Product product = findProduct(productId); 262 | product.setStatus(ProductStatus.PENDING); 263 | 264 | ProductRef productRef = productRefRepository.findFirstByProductId(productId) 265 | .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_REQUEST, "API产品未关联API")); 266 | 267 | // 已发布的产品不允许解绑 268 | if (publicationRepository.existsByProductId(productId)) { 269 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "API产品已发布"); 270 | } 271 | 272 | productRefRepository.delete(productRef); 273 | productRepository.save(product); 274 | } 275 | 276 | private void syncConfig(Product product, ProductRef productRef) { 277 | SourceType sourceType = productRef.getSourceType(); 278 | 279 | if (sourceType.isGateway()) { 280 | GatewayResult gateway = gatewayService.getGateway(productRef.getGatewayId()); 281 | Object config = gateway.getGatewayType().isHigress() ? productRef.getHigressRefConfig() : gateway.getGatewayType().isAdpAIGateway() ? productRef.getAdpAIGatewayRefConfig() : productRef.getApigRefConfig(); 282 | if (product.getType() == ProductType.REST_API) { 283 | String apiConfig = gatewayService.fetchAPIConfig(gateway.getGatewayId(), config); 284 | productRef.setApiConfig(apiConfig); 285 | } else { 286 | String mcpConfig = gatewayService.fetchMcpConfig(gateway.getGatewayId(), config); 287 | productRef.setMcpConfig(mcpConfig); 288 | } 289 | } else if (sourceType.isNacos()) { 290 | // 从Nacos获取MCP Server配置 291 | NacosRefConfig nacosRefConfig = productRef.getNacosRefConfig(); 292 | if (nacosRefConfig != null) { 293 | String mcpConfig = nacosService.fetchMcpConfig(productRef.getNacosId(), nacosRefConfig); 294 | productRef.setMcpConfig(mcpConfig); 295 | } 296 | } 297 | product.setStatus(ProductStatus.READY); 298 | productRef.setEnabled(true); 299 | } 300 | 301 | private void fullFillProduct(ProductResult product) { 302 | productRefRepository.findFirstByProductId(product.getProductId()) 303 | .ifPresent(productRef -> { 304 | product.setEnabled(productRef.getEnabled()); 305 | if (StrUtil.isNotBlank(productRef.getApiConfig())) { 306 | product.setApiConfig(JSONUtil.toBean(productRef.getApiConfig(), APIConfigResult.class)); 307 | } 308 | 309 | // API Config 310 | if (StrUtil.isNotBlank(productRef.getMcpConfig())) { 311 | product.setMcpConfig(JSONUtil.toBean(productRef.getMcpConfig(), MCPConfigResult.class)); 312 | } 313 | product.setStatus(ProductStatus.READY); 314 | }); 315 | 316 | if (publicationRepository.existsByProductId(product.getProductId())) { 317 | product.setStatus(ProductStatus.PUBLISHED); 318 | } 319 | } 320 | 321 | private Product findPublishedProduct(String portalId, String productId) { 322 | ProductPublication publication = publicationRepository.findByPortalIdAndProductId(portalId, productId) 323 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, productId)); 324 | 325 | return findProduct(publication.getProductId()); 326 | } 327 | 328 | private Specification<Product> buildSpecification(QueryProductParam param) { 329 | return (root, query, cb) -> { 330 | List<Predicate> predicates = new ArrayList<>(); 331 | 332 | if (StrUtil.isNotBlank(param.getPortalId())) { 333 | Subquery<String> subquery = query.subquery(String.class); 334 | Root<ProductPublication> publicationRoot = subquery.from(ProductPublication.class); 335 | subquery.select(publicationRoot.get("productId")) 336 | .where(cb.equal(publicationRoot.get("portalId"), param.getPortalId())); 337 | predicates.add(root.get("productId").in(subquery)); 338 | } 339 | 340 | if (param.getType() != null) { 341 | predicates.add(cb.equal(root.get("type"), param.getType())); 342 | } 343 | 344 | if (StrUtil.isNotBlank(param.getCategory())) { 345 | predicates.add(cb.equal(root.get("category"), param.getCategory())); 346 | } 347 | 348 | if (param.getStatus() != null) { 349 | predicates.add(cb.equal(root.get("status"), param.getStatus())); 350 | } 351 | 352 | if (StrUtil.isNotBlank(param.getName())) { 353 | String likePattern = "%" + param.getName() + "%"; 354 | predicates.add(cb.like(root.get("name"), likePattern)); 355 | } 356 | 357 | return cb.and(predicates.toArray(new Predicate[0])); 358 | }; 359 | } 360 | 361 | @EventListener 362 | @Async("taskExecutor") 363 | @Override 364 | public void handlePortalDeletion(PortalDeletingEvent event) { 365 | String portalId = event.getPortalId(); 366 | try { 367 | log.info("Starting to cleanup publications for portal {}", portalId); 368 | publicationRepository.deleteAllByPortalId(portalId); 369 | 370 | log.info("Completed cleanup publications for portal {}", portalId); 371 | } catch (Exception e) { 372 | log.error("Failed to cleanup developers for portal {}: {}", portalId, e.getMessage()); 373 | } 374 | } 375 | 376 | @Override 377 | public Map<String, ProductResult> getProducts(List<String> productIds) { 378 | List<Product> products = productRepository.findByProductIdIn(productIds); 379 | return products.stream() 380 | .collect(Collectors.toMap(Product::getProductId, product -> new ProductResult().convertFrom(product))); 381 | } 382 | 383 | @Override 384 | public String getProductDashboard(String productId) { 385 | // 获取产品关联的网关信息 386 | ProductRef productRef = productRefRepository.findFirstByProductId(productId) 387 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, productId)); 388 | 389 | if (productRef.getGatewayId() == null) { 390 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "该产品尚未关联网关服务"); 391 | } 392 | // 基于产品类型选择Dashboard类型 393 | Product product = findProduct(productId); 394 | String dashboardType; 395 | if (product.getType() == ProductType.MCP_SERVER) { 396 | dashboardType = "MCP"; 397 | } else { 398 | // REST_API、HTTP_API 统一走 API 面板 399 | dashboardType = "API"; 400 | } 401 | // 通过网关服务获取Dashboard URL 402 | return gatewayService.getDashboard(productRef.getGatewayId(), dashboardType); 403 | } 404 | 405 | @Override 406 | public PageResult<SubscriptionResult> listProductSubscriptions(String productId, QueryProductSubscriptionParam param, Pageable pageable) { 407 | existsProduct(productId); 408 | Page<ProductSubscription> subscriptions = subscriptionRepository.findAll(buildProductSubscriptionSpec(productId, param), pageable); 409 | 410 | List<String> consumerIds = subscriptions.getContent().stream() 411 | .map(ProductSubscription::getConsumerId) 412 | .collect(Collectors.toList()); 413 | if (CollUtil.isEmpty(consumerIds)) { 414 | return PageResult.empty(pageable.getPageNumber(), pageable.getPageSize()); 415 | } 416 | 417 | Map<String, Consumer> consumers = consumerRepository.findByConsumerIdIn(consumerIds) 418 | .stream() 419 | .collect(Collectors.toMap(Consumer::getConsumerId, consumer -> consumer)); 420 | 421 | return new PageResult<SubscriptionResult>().convertFrom(subscriptions, s -> { 422 | SubscriptionResult r = new SubscriptionResult().convertFrom(s); 423 | Consumer consumer = consumers.get(r.getConsumerId()); 424 | if (consumer != null) { 425 | r.setConsumerName(consumer.getName()); 426 | } 427 | return r; 428 | }); 429 | } 430 | 431 | @Override 432 | public void existsProduct(String productId) { 433 | productRepository.findByProductId(productId) 434 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, productId)); 435 | } 436 | 437 | private Specification<ProductSubscription> buildProductSubscriptionSpec(String productId, QueryProductSubscriptionParam param) { 438 | return (root, query, cb) -> { 439 | List<Predicate> predicates = new ArrayList<>(); 440 | predicates.add(cb.equal(root.get("productId"), productId)); 441 | 442 | // 如果是开发者,只能查看自己的Consumer订阅 443 | if (contextHolder.isDeveloper()) { 444 | Subquery<String> consumerSubquery = query.subquery(String.class); 445 | Root<Consumer> consumerRoot = consumerSubquery.from(Consumer.class); 446 | consumerSubquery.select(consumerRoot.get("consumerId")) 447 | .where(cb.equal(consumerRoot.get("developerId"), contextHolder.getUser())); 448 | 449 | predicates.add(root.get("consumerId").in(consumerSubquery)); 450 | } 451 | 452 | if (param.getStatus() != null) { 453 | predicates.add(cb.equal(root.get("status"), param.getStatus())); 454 | } 455 | 456 | if (StrUtil.isNotBlank(param.getConsumerName())) { 457 | Subquery<String> consumerSubquery = query.subquery(String.class); 458 | Root<Consumer> consumerRoot = consumerSubquery.from(Consumer.class); 459 | 460 | consumerSubquery.select(consumerRoot.get("consumerId")) 461 | .where(cb.like( 462 | cb.lower(consumerRoot.get("name")), 463 | "%" + param.getConsumerName().toLowerCase() + "%" 464 | )); 465 | 466 | predicates.add(root.get("consumerId").in(consumerSubquery)); 467 | } 468 | 469 | return cb.and(predicates.toArray(new Predicate[0])); 470 | }; 471 | } 472 | } 473 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/components/ProductHeader.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import React, { useState, useEffect } from "react"; 2 | import { Typography, Button, Modal, Select, message, Popconfirm, Input, Pagination, Spin } from "antd"; 3 | import { ApiOutlined, CheckCircleFilled, ClockCircleFilled, ExclamationCircleFilled, PlusOutlined } from "@ant-design/icons"; 4 | import { useParams } from "react-router-dom"; 5 | import { getConsumers, subscribeProduct, getProductSubscriptionStatus, unsubscribeProduct, getProductSubscriptions } from "../lib/api"; 6 | import type { Consumer } from "../types/consumer"; 7 | import type { McpConfig, ProductIcon } from "../types"; 8 | 9 | const { Title, Paragraph } = Typography; 10 | const { Search } = Input; 11 | 12 | interface ProductHeaderProps { 13 | name: string; 14 | description: string; 15 | icon?: ProductIcon | null; 16 | defaultIcon?: string; 17 | mcpConfig?: McpConfig | null; 18 | updatedAt?: string; 19 | productType?: 'REST_API' | 'MCP_SERVER'; 20 | } 21 | 22 | // 处理产品图标的函数 23 | const getIconUrl = (icon?: ProductIcon | null, defaultIcon?: string): string => { 24 | const fallback = defaultIcon || "/logo.svg"; 25 | 26 | if (!icon) { 27 | return fallback; 28 | } 29 | 30 | switch (icon.type) { 31 | case "URL": 32 | return icon.value || fallback; 33 | case "BASE64": 34 | // 如果value已经包含data URL前缀,直接使用;否则添加前缀 35 | return icon.value ? (icon.value.startsWith('data:') ? icon.value : `data:image/png;base64,${icon.value}`) : fallback; 36 | default: 37 | return fallback; 38 | } 39 | }; 40 | 41 | export const ProductHeader: React.FC<ProductHeaderProps> = ({ 42 | name, 43 | description, 44 | icon, 45 | defaultIcon = "/default-icon.png", 46 | mcpConfig, 47 | updatedAt, 48 | productType, 49 | }) => { 50 | const { id, mcpName } = useParams(); 51 | const [isManageModalVisible, setIsManageModalVisible] = useState(false); 52 | const [isApplyingSubscription, setIsApplyingSubscription] = useState(false); 53 | const [selectedConsumerId, setSelectedConsumerId] = useState<string>(''); 54 | const [consumers, setConsumers] = useState<Consumer[]>([]); 55 | 56 | // 分页相关state 57 | const [currentPage, setCurrentPage] = useState(1); 58 | const [pageSize, setPageSize] = useState(5); // 每页显示5个订阅 59 | 60 | // 分开管理不同的loading状态 61 | const [consumersLoading, setConsumersLoading] = useState(false); 62 | const [submitLoading, setSubmitLoading] = useState(false); 63 | const [imageLoadFailed, setImageLoadFailed] = useState(false); 64 | 65 | // 订阅状态相关的state 66 | const [subscriptionStatus, setSubscriptionStatus] = useState<{ 67 | hasSubscription: boolean; 68 | subscribedConsumers: any[]; 69 | allConsumers: any[]; 70 | fullSubscriptionData?: { 71 | content: any[]; 72 | totalElements: number; 73 | totalPages: number; 74 | }; 75 | } | null>(null); 76 | const [subscriptionLoading, setSubscriptionLoading] = useState(false); 77 | 78 | // 订阅详情分页数据(用于管理弹窗) 79 | const [subscriptionDetails, setSubscriptionDetails] = useState<{ 80 | content: any[]; 81 | totalElements: number; 82 | totalPages: number; 83 | }>({ content: [], totalElements: 0, totalPages: 0 }); 84 | const [detailsLoading, setDetailsLoading] = useState(false); 85 | 86 | // 搜索相关state 87 | const [searchKeyword, setSearchKeyword] = useState(""); 88 | 89 | // 判断是否应该显示申请订阅按钮 90 | const shouldShowSubscribeButton = !mcpConfig || mcpConfig.meta.source !== 'NACOS'; 91 | 92 | // 获取产品ID 93 | const productId = id || mcpName || ''; 94 | 95 | // 查询订阅状态 96 | const fetchSubscriptionStatus = async () => { 97 | if (!productId || !shouldShowSubscribeButton) return; 98 | 99 | setSubscriptionLoading(true); 100 | try { 101 | const status = await getProductSubscriptionStatus(productId); 102 | setSubscriptionStatus(status); 103 | } catch (error) { 104 | console.error('获取订阅状态失败:', error); 105 | } finally { 106 | setSubscriptionLoading(false); 107 | } 108 | }; 109 | 110 | // 获取订阅详情(用于管理弹窗) 111 | const fetchSubscriptionDetails = async (page: number = 1, search: string = ''): Promise<void> => { 112 | if (!productId) return Promise.resolve(); 113 | 114 | setDetailsLoading(true); 115 | try { 116 | const response = await getProductSubscriptions(productId, { 117 | consumerName: search.trim() || undefined, 118 | page: page - 1, // 后端使用0基索引 119 | size: pageSize 120 | }); 121 | 122 | setSubscriptionDetails({ 123 | content: response.data.content || [], 124 | totalElements: response.data.totalElements || 0, 125 | totalPages: response.data.totalPages || 0 126 | }); 127 | } catch (error) { 128 | console.error('获取订阅详情失败:', error); 129 | message.error('获取订阅详情失败,请重试'); 130 | } finally { 131 | setDetailsLoading(false); 132 | } 133 | }; 134 | 135 | useEffect(() => { 136 | fetchSubscriptionStatus(); 137 | }, [productId, shouldShowSubscribeButton]); 138 | 139 | // 获取消费者列表 140 | const fetchConsumers = async () => { 141 | try { 142 | setConsumersLoading(true); 143 | const response = await getConsumers({}, { page: 1, size: 100 }); 144 | if (response.data) { 145 | setConsumers(response.data.content || response.data); 146 | } 147 | } catch (error) { 148 | // message.error('获取消费者列表失败'); 149 | } finally { 150 | setConsumersLoading(false); 151 | } 152 | }; 153 | 154 | // 开始申请订阅流程 155 | const startApplyingSubscription = () => { 156 | setIsApplyingSubscription(true); 157 | setSelectedConsumerId(''); 158 | fetchConsumers(); 159 | }; 160 | 161 | // 取消申请订阅 162 | const cancelApplyingSubscription = () => { 163 | setIsApplyingSubscription(false); 164 | setSelectedConsumerId(''); 165 | }; 166 | 167 | // 提交申请订阅 168 | const handleApplySubscription = async () => { 169 | if (!selectedConsumerId) { 170 | message.warning('请选择消费者'); 171 | return; 172 | } 173 | 174 | try { 175 | setSubmitLoading(true); 176 | await subscribeProduct(selectedConsumerId, productId); 177 | message.success('申请提交成功'); 178 | 179 | // 重置状态 180 | setIsApplyingSubscription(false); 181 | setSelectedConsumerId(''); 182 | 183 | // 重新获取订阅状态和详情数据 184 | await fetchSubscriptionStatus(); 185 | await fetchSubscriptionDetails(currentPage, ''); 186 | } catch (error) { 187 | console.error('申请订阅失败:', error); 188 | message.error('申请提交失败,请重试'); 189 | } finally { 190 | setSubmitLoading(false); 191 | } 192 | }; 193 | 194 | // 显示管理弹窗 195 | const showManageModal = () => { 196 | setIsManageModalVisible(true); 197 | 198 | // 优先使用已缓存的数据,避免重复查询 199 | if (subscriptionStatus?.fullSubscriptionData) { 200 | setSubscriptionDetails({ 201 | content: subscriptionStatus.fullSubscriptionData.content, 202 | totalElements: subscriptionStatus.fullSubscriptionData.totalElements, 203 | totalPages: subscriptionStatus.fullSubscriptionData.totalPages 204 | }); 205 | // 重置分页到第一页 206 | setCurrentPage(1); 207 | setSearchKeyword(''); 208 | } else { 209 | // 如果没有缓存数据,则重新获取 210 | fetchSubscriptionDetails(1, ''); 211 | } 212 | }; 213 | 214 | // 处理搜索输入变化 215 | const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => { 216 | const value = e.target.value; 217 | setSearchKeyword(value); 218 | // 只更新状态,不触发搜索 219 | }; 220 | 221 | // 执行搜索 222 | const handleSearch = (value?: string) => { 223 | // 如果传入了value参数,使用该参数;否则使用当前的searchKeyword 224 | const keyword = value !== undefined ? value : searchKeyword; 225 | const trimmedKeyword = keyword.trim(); 226 | setCurrentPage(1); 227 | 228 | // 总是调用API进行搜索,不使用缓存 229 | fetchSubscriptionDetails(1, trimmedKeyword); 230 | }; 231 | 232 | // 处理回车键搜索 233 | const handleSearchKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { 234 | if (e.key === 'Enter') { 235 | handleSearch(); 236 | } 237 | }; 238 | 239 | 240 | // 隐藏管理弹窗 241 | const handleManageCancel = () => { 242 | setIsManageModalVisible(false); 243 | // 重置申请订阅状态 244 | setIsApplyingSubscription(false); 245 | setSelectedConsumerId(''); 246 | // 重置分页和搜索 247 | setCurrentPage(1); 248 | setSearchKeyword(''); 249 | // 清空订阅详情数据 250 | setSubscriptionDetails({ content: [], totalElements: 0, totalPages: 0 }); 251 | }; 252 | 253 | // 取消订阅 254 | const handleUnsubscribe = async (consumerId: string) => { 255 | try { 256 | await unsubscribeProduct(consumerId, productId); 257 | message.success('取消订阅成功'); 258 | 259 | // 重新获取订阅状态和详情数据 260 | await fetchSubscriptionStatus(); 261 | await fetchSubscriptionDetails(currentPage, ''); 262 | } catch (error) { 263 | console.error('取消订阅失败:', error); 264 | message.error('取消订阅失败,请重试'); 265 | } 266 | }; 267 | 268 | return ( 269 | <> 270 | <div className="mb-2"> 271 | {/* 第一行:图标和标题信息 */} 272 | <div className="flex items-center gap-4 mb-3"> 273 | {(!icon || imageLoadFailed) && productType === 'REST_API' ? ( 274 | <div className="w-16 h-16 rounded-xl flex-shrink-0 flex items-center justify-center bg-gray-50 border border-gray-200"> 275 | <ApiOutlined className="text-3xl text-black" /> 276 | </div> 277 | ) : ( 278 | <img 279 | src={getIconUrl(icon, defaultIcon)} 280 | alt="icon" 281 | className="w-16 h-16 rounded-xl object-cover border border-gray-200 flex-shrink-0" 282 | onError={(e) => { 283 | const target = e.target as HTMLImageElement; 284 | if (productType === 'REST_API') { 285 | setImageLoadFailed(true); 286 | } else { 287 | // 确保有一个最终的fallback图片,避免无限循环请求 288 | const fallbackIcon = defaultIcon || "/logo.svg"; 289 | const currentUrl = new URL(target.src, window.location.href).href; 290 | const fallbackUrl = new URL(fallbackIcon, window.location.href).href; 291 | if (currentUrl !== fallbackUrl) { 292 | target.src = fallbackIcon; 293 | } 294 | } 295 | }} 296 | /> 297 | )} 298 | <div className="flex-1 min-w-0 flex flex-col justify-center"> 299 | <Title level={3} className="mb-1 text-xl font-semibold"> 300 | {name} 301 | </Title> 302 | {updatedAt && ( 303 | <div className="text-sm text-gray-400"> 304 | {new Date(updatedAt).toLocaleDateString('zh-CN', { 305 | year: 'numeric', 306 | month: '2-digit', 307 | day: '2-digit' 308 | }).replace(/\//g, '.')} updated 309 | </div> 310 | )} 311 | </div> 312 | </div> 313 | 314 | {/* 第二行:描述信息,与左边框对齐 */} 315 | <Paragraph className="text-gray-600 mb-3 text-sm leading-relaxed"> 316 | {description} 317 | </Paragraph> 318 | 319 | {/* 第三行:徽章式订阅状态 + 管理按钮,与左边框对齐 */} 320 | {shouldShowSubscribeButton && ( 321 | <div className="flex items-center gap-4"> 322 | {subscriptionLoading ? ( 323 | <Button loading>加载中...</Button> 324 | ) : ( 325 | <> 326 | {/* 订阅状态徽章 */} 327 | <div className="flex items-center"> 328 | {subscriptionStatus?.hasSubscription ? ( 329 | <> 330 | <div className="w-2 h-2 bg-green-500 rounded-full mr-2"></div> 331 | <span className="text-sm text-gray-600 font-medium">已订阅</span> 332 | </> 333 | ) : ( 334 | <> 335 | <div className="w-2 h-2 bg-gray-400 rounded-full mr-2"></div> 336 | <span className="text-sm text-gray-600">未订阅</span> 337 | </> 338 | )} 339 | </div> 340 | 341 | {/* 管理按钮 */} 342 | <Button 343 | type="primary" 344 | onClick={showManageModal} 345 | > 346 | 管理订阅 347 | </Button> 348 | </> 349 | )} 350 | </div> 351 | )} 352 | </div> 353 | 354 | 355 | {/* 订阅管理弹窗 */} 356 | <Modal 357 | title="订阅管理" 358 | open={isManageModalVisible} 359 | onCancel={handleManageCancel} 360 | footer={null} 361 | width={600} 362 | styles={{ 363 | content: { 364 | borderRadius: '8px', 365 | padding: 0 366 | }, 367 | header: { 368 | borderRadius: '8px 8px 0 0', 369 | marginBottom: 0, 370 | paddingBottom: '8px' 371 | }, 372 | body: { 373 | padding: '0px' 374 | } 375 | }} 376 | > 377 | <div className="px-6 py-4"> 378 | {/* 产品名称标识 - 容器框样式 */} 379 | <div className="mb-4"> 380 | <div className="bg-blue-50 border border-blue-200 rounded px-3 py-2"> 381 | <span className="text-sm text-gray-600 mr-2">产品名称:</span> 382 | <span className="text-sm text-gray-600">{name}</span> 383 | </div> 384 | </div> 385 | 386 | {/* 搜索框 */} 387 | <div className="mb-4"> 388 | <Search 389 | placeholder="搜索消费者名称" 390 | value={searchKeyword} 391 | onChange={handleSearchChange} 392 | onSearch={handleSearch} 393 | onPressEnter={handleSearchKeyPress} 394 | allowClear 395 | style={{ width: 250 }} 396 | /> 397 | </div> 398 | 399 | {/* 优化的表格式 - 无表头,内嵌分页 */} 400 | <div className="border border-gray-200 rounded overflow-hidden"> 401 | {detailsLoading ? ( 402 | <div className="p-8 text-center"> 403 | <Spin size="large" /> 404 | </div> 405 | ) : subscriptionDetails.content && subscriptionDetails.content.length > 0 ? ( 406 | <> 407 | {/* 表格内容 */} 408 | <div className="divide-y divide-gray-100"> 409 | {(searchKeyword.trim() 410 | ? subscriptionDetails.content 411 | : subscriptionDetails.content.slice((currentPage - 1) * pageSize, currentPage * pageSize) 412 | ).map((item) => ( 413 | <div key={item.consumerId} className="flex items-center px-4 py-3 hover:bg-gray-50"> 414 | {/* 消费者名称 - 40% */} 415 | <div className="flex-1 min-w-0 pr-4"> 416 | <span className="text-sm text-gray-700 truncate block"> 417 | {item.consumerName} 418 | </span> 419 | </div> 420 | {/* 状态 - 30% */} 421 | <div className="w-24 flex items-center pr-4"> 422 | {item.status === 'APPROVED' ? ( 423 | <> 424 | <CheckCircleFilled className="text-green-500 mr-1" style={{fontSize: '10px'}} /> 425 | <span className="text-xs text-gray-700">已通过</span> 426 | </> 427 | ) : item.status === 'PENDING' ? ( 428 | <> 429 | <ClockCircleFilled className="text-blue-500 mr-1" style={{fontSize: '10px'}} /> 430 | <span className="text-xs text-gray-700">审核中</span> 431 | </> 432 | ) : ( 433 | <> 434 | <ExclamationCircleFilled className="text-red-500 mr-1" style={{fontSize: '10px'}} /> 435 | <span className="text-xs text-gray-700">已拒绝</span> 436 | </> 437 | )} 438 | </div> 439 | 440 | {/* 操作 - 30% */} 441 | <div className="w-20"> 442 | <Popconfirm 443 | title="确定要取消订阅吗?" 444 | onConfirm={() => handleUnsubscribe(item.consumerId)} 445 | okText="确认" 446 | cancelText="取消" 447 | > 448 | <Button type="link" danger size="small" className="p-0"> 449 | 取消订阅 450 | </Button> 451 | </Popconfirm> 452 | </div> 453 | </div> 454 | ))} 455 | </div> 456 | </> 457 | ) : ( 458 | <div className="p-8 text-center text-gray-500"> 459 | {searchKeyword ? '未找到匹配的订阅记录' : '暂无订阅记录'} 460 | </div> 461 | )} 462 | </div> 463 | 464 | {/* 分页 - 使用Ant Design分页组件,右对齐 */} 465 | {subscriptionDetails.totalElements > 0 && ( 466 | <div className="mt-3 flex justify-end"> 467 | <Pagination 468 | current={currentPage} 469 | total={subscriptionDetails.totalElements} 470 | pageSize={pageSize} 471 | size="small" 472 | showSizeChanger={true} 473 | showQuickJumper={true} 474 | onChange={(page, size) => { 475 | setCurrentPage(page); 476 | if (size !== pageSize) { 477 | setPageSize(size); 478 | } 479 | 480 | // 如果有搜索关键词,需要重新查询;否则使用缓存数据 481 | if (searchKeyword.trim()) { 482 | fetchSubscriptionDetails(page, searchKeyword); 483 | } 484 | // 无搜索时不需要重新查询,Ant Design会自动处理前端分页 485 | }} 486 | onShowSizeChange={(_current, size) => { 487 | setPageSize(size); 488 | setCurrentPage(1); 489 | 490 | // 如果有搜索关键词,需要重新查询;否则使用缓存数据 491 | if (searchKeyword.trim()) { 492 | fetchSubscriptionDetails(1, searchKeyword); 493 | } 494 | // 无搜索时不需要重新查询,页面大小变化会自动重新渲染 495 | }} 496 | showTotal={(total) => `共 ${total} 条`} 497 | pageSizeOptions={['5', '10', '20']} 498 | hideOnSinglePage={false} 499 | /> 500 | </div> 501 | )} 502 | 503 | {/* 申请订阅区域 - 移回底部 */} 504 | <div className={`border-t pt-3 ${subscriptionDetails.totalElements > 0 ? 'mt-4' : 'mt-2'}`}> 505 | <div className="flex justify-end"> 506 | {!isApplyingSubscription ? ( 507 | <Button 508 | type="primary" 509 | icon={<PlusOutlined />} 510 | onClick={startApplyingSubscription} 511 | > 512 | 订阅 513 | </Button> 514 | ) : ( 515 | <div className="w-full"> 516 | <div className="bg-gray-50 p-4 rounded"> 517 | <div className="mb-4"> 518 | <label className="block text-sm font-medium text-gray-700 mb-2"> 519 | 选择消费者 520 | </label> 521 | <Select 522 | placeholder="搜索或选择消费者" 523 | style={{ width: '100%' }} 524 | value={selectedConsumerId} 525 | onChange={setSelectedConsumerId} 526 | showSearch 527 | loading={consumersLoading} 528 | filterOption={(input, option) => 529 | (option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase()) 530 | } 531 | notFoundContent={consumersLoading ? '加载中...' : '暂无消费者数据'} 532 | > 533 | {consumers 534 | .filter(consumer => { 535 | // 过滤掉已经订阅的consumer 536 | const isAlreadySubscribed = subscriptionStatus?.subscribedConsumers?.some( 537 | item => item.consumer.consumerId === consumer.consumerId 538 | ); 539 | return !isAlreadySubscribed; 540 | }) 541 | .map(consumer => ( 542 | <Select.Option key={consumer.consumerId} value={consumer.consumerId}> 543 | {consumer.name} 544 | </Select.Option> 545 | )) 546 | } 547 | </Select> 548 | </div> 549 | <div className="flex justify-end gap-2"> 550 | <Button onClick={cancelApplyingSubscription}> 551 | 取消 552 | </Button> 553 | <Button 554 | type="primary" 555 | loading={submitLoading} 556 | disabled={!selectedConsumerId} 557 | onClick={handleApplySubscription} 558 | > 559 | 确认申请 560 | </Button> 561 | </div> 562 | </div> 563 | </div> 564 | )} 565 | </div> 566 | </div> 567 | </div> 568 | </Modal> 569 | </> 570 | ); 571 | }; ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductApiDocs.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Card, Tag, Tabs, Table, Collapse, Descriptions } from "antd"; 2 | import { useEffect, useMemo, useState } from "react"; 3 | import type { ApiProduct } from "@/types/api-product"; 4 | import MonacoEditor from "react-monaco-editor"; 5 | import * as yaml from "js-yaml"; 6 | import { ProductTypeMap } from "@/lib/utils"; 7 | 8 | // 来源类型映射 9 | const FromTypeMap: Record<string, string> = { 10 | HTTP: "HTTP转MCP", 11 | MCP: "MCP直接代理", 12 | OPEN_API: "OpenAPI转MCP", 13 | DIRECT_ROUTE: "直接路由", 14 | DATABASE: "数据库", 15 | }; 16 | 17 | // 来源映射 18 | const SourceMap: Record<string, string> = { 19 | APIG_AI: "AI网关", 20 | HIGRESS: "Higress", 21 | NACOS: "Nacos", 22 | APIG_API: "API网关" 23 | }; 24 | 25 | interface ApiProductApiDocsProps { 26 | apiProduct: ApiProduct; 27 | handleRefresh: () => void; 28 | } 29 | 30 | export function ApiProductApiDocs({ apiProduct }: ApiProductApiDocsProps) { 31 | const [content, setContent] = useState(""); 32 | 33 | // OpenAPI 端点 34 | const [endpoints, setEndpoints] = useState< 35 | Array<{ 36 | key: string; 37 | method: string; 38 | path: string; 39 | description: string; 40 | operationId?: string; 41 | }> 42 | >([]); 43 | 44 | // MCP 配置解析结果 45 | const [mcpParsed, setMcpParsed] = useState<{ 46 | server?: { name?: string; config?: Record<string, unknown> }; 47 | tools?: Array<{ 48 | name: string; 49 | description?: string; 50 | args?: Array<{ 51 | name: string; 52 | description?: string; 53 | type?: string; 54 | required?: boolean; 55 | position?: string; 56 | defaultValue?: string | number | boolean | null; 57 | enumValues?: Array<string> | null; 58 | }>; 59 | }>; 60 | allowTools?: Array<string>; 61 | }>({}); 62 | 63 | // MCP 连接配置JSON 64 | const [httpJson, setHttpJson] = useState(""); 65 | const [sseJson, setSseJson] = useState(""); 66 | const [localJson, setLocalJson] = useState(""); 67 | 68 | // 生成连接配置JSON 69 | const generateConnectionConfig = ( 70 | domains: Array<{ domain: string; protocol: string }> | null | undefined, 71 | path: string | null | undefined, 72 | serverName: string, 73 | localConfig?: unknown, 74 | protocolType?: string 75 | ) => { 76 | // 互斥:优先判断本地模式 77 | if (localConfig) { 78 | const localConfigJson = JSON.stringify(localConfig, null, 2); 79 | setLocalJson(localConfigJson); 80 | setHttpJson(""); 81 | setSseJson(""); 82 | return; 83 | } 84 | 85 | // HTTP/SSE 模式 86 | if (domains && domains.length > 0 && path) { 87 | const domain = domains[0]; 88 | const baseUrl = `${domain.protocol}://${domain.domain}`; 89 | const endpoint = `${baseUrl}${path}`; 90 | 91 | if (protocolType === 'SSE') { 92 | // 仅生成SSE配置,不追加/sse 93 | const sseConfig = `{ 94 | "mcpServers": { 95 | "${serverName}": { 96 | "type": "sse", 97 | "url": "${endpoint}" 98 | } 99 | } 100 | }`; 101 | setSseJson(sseConfig); 102 | setHttpJson(""); 103 | setLocalJson(""); 104 | return; 105 | } else if (protocolType === 'StreamableHTTP') { 106 | // 仅生成HTTP配置 107 | const httpConfig = `{ 108 | "mcpServers": { 109 | "${serverName}": { 110 | "url": "${endpoint}" 111 | } 112 | } 113 | }`; 114 | setHttpJson(httpConfig); 115 | setSseJson(""); 116 | setLocalJson(""); 117 | return; 118 | } else { 119 | // protocol为null或其他值:生成两种配置 120 | const httpConfig = `{ 121 | "mcpServers": { 122 | "${serverName}": { 123 | "url": "${endpoint}" 124 | } 125 | } 126 | }`; 127 | 128 | const sseConfig = `{ 129 | "mcpServers": { 130 | "${serverName}": { 131 | "type": "sse", 132 | "url": "${endpoint}/sse" 133 | } 134 | } 135 | }`; 136 | 137 | setHttpJson(httpConfig); 138 | setSseJson(sseConfig); 139 | setLocalJson(""); 140 | return; 141 | } 142 | } 143 | 144 | // 无有效配置 145 | setHttpJson(""); 146 | setSseJson(""); 147 | setLocalJson(""); 148 | }; 149 | 150 | 151 | useEffect(() => { 152 | // 设置源码内容 153 | if (apiProduct.apiConfig?.spec) { 154 | setContent(apiProduct.apiConfig.spec); 155 | } else if (apiProduct.mcpConfig?.tools) { 156 | setContent(apiProduct.mcpConfig.tools); 157 | } else { 158 | setContent(""); 159 | } 160 | 161 | // 解析 OpenAPI(如有) 162 | if (apiProduct.apiConfig?.spec) { 163 | const spec = apiProduct.apiConfig.spec; 164 | try { 165 | const list: Array<{ 166 | key: string; 167 | method: string; 168 | path: string; 169 | description: string; 170 | operationId?: string; 171 | }> = []; 172 | 173 | const lines = spec.split("\n"); 174 | let currentPath = ""; 175 | let inPaths = false; 176 | 177 | for (let i = 0; i < lines.length; i++) { 178 | const line = lines[i]; 179 | const trimmedLine = line.trim(); 180 | const indentLevel = line.length - line.trimStart().length; 181 | 182 | if (trimmedLine === "paths:" || trimmedLine.startsWith("paths:")) { 183 | inPaths = true; 184 | continue; 185 | } 186 | if (!inPaths) continue; 187 | 188 | if ( 189 | inPaths && 190 | indentLevel === 2 && 191 | trimmedLine.startsWith("/") && 192 | trimmedLine.endsWith(":") 193 | ) { 194 | currentPath = trimmedLine.slice(0, -1); 195 | continue; 196 | } 197 | 198 | if (inPaths && indentLevel === 4) { 199 | const httpMethods = [ 200 | "get:", 201 | "post:", 202 | "put:", 203 | "delete:", 204 | "patch:", 205 | "head:", 206 | "options:", 207 | ]; 208 | for (const method of httpMethods) { 209 | if (trimmedLine.startsWith(method)) { 210 | const methodName = method.replace(":", "").toUpperCase(); 211 | const operationId = extractOperationId(lines, i); 212 | list.push({ 213 | key: `${methodName}-${currentPath}`, 214 | method: methodName, 215 | path: currentPath, 216 | description: operationId || `${methodName} ${currentPath}`, 217 | operationId, 218 | }); 219 | break; 220 | } 221 | } 222 | } 223 | } 224 | 225 | setEndpoints(list.length > 0 ? list : []); 226 | } catch { 227 | setEndpoints([]); 228 | } 229 | } else { 230 | setEndpoints([]); 231 | } 232 | 233 | // 解析 MCP YAML(如有) 234 | if (apiProduct.mcpConfig?.tools) { 235 | try { 236 | const doc = yaml.load(apiProduct.mcpConfig.tools) as any; 237 | const toolsRaw = Array.isArray(doc?.tools) ? doc.tools : []; 238 | const tools = toolsRaw.map((t: any) => ({ 239 | name: String(t?.name ?? ""), 240 | description: t?.description ? String(t.description) : undefined, 241 | args: Array.isArray(t?.args) 242 | ? t.args.map((a: any) => ({ 243 | name: String(a?.name ?? ""), 244 | description: a?.description ? String(a.description) : undefined, 245 | type: a?.type ? String(a.type) : undefined, 246 | required: Boolean(a?.required), 247 | position: a?.position ? String(a.position) : undefined, 248 | defaultValue: a?.defaultValue ?? a?.default ?? null, 249 | enumValues: a?.enumValues ?? a?.enum ?? null, 250 | })) 251 | : undefined, 252 | })); 253 | 254 | setMcpParsed({ 255 | server: doc?.server, 256 | tools, 257 | allowTools: Array.isArray(doc?.allowTools) 258 | ? doc.allowTools 259 | : undefined, 260 | }); 261 | 262 | // 生成连接配置JSON test 263 | generateConnectionConfig( 264 | apiProduct.mcpConfig.mcpServerConfig?.domains, 265 | apiProduct.mcpConfig.mcpServerConfig?.path, 266 | apiProduct.mcpConfig.mcpServerName, 267 | apiProduct.mcpConfig.mcpServerConfig?.rawConfig, 268 | apiProduct.mcpConfig.meta?.protocol 269 | ); 270 | } catch { 271 | setMcpParsed({}); 272 | } 273 | } else { 274 | setMcpParsed({}); 275 | } 276 | }, [apiProduct]); 277 | 278 | const isOpenApi = useMemo( 279 | () => Boolean(apiProduct.apiConfig?.spec), 280 | [apiProduct] 281 | ); 282 | const isMcp = useMemo( 283 | () => Boolean(apiProduct.mcpConfig?.tools), 284 | [apiProduct] 285 | ); 286 | 287 | const openApiColumns = useMemo( 288 | () => [ 289 | { 290 | title: "方法", 291 | dataIndex: "method", 292 | key: "method", 293 | width: 100, 294 | render: (method: string) => ( 295 | <span> 296 | <Tag 297 | color={ 298 | method === "GET" 299 | ? "green" 300 | : method === "POST" 301 | ? "blue" 302 | : method === "PUT" 303 | ? "orange" 304 | : method === "DELETE" 305 | ? "red" 306 | : "default" 307 | } 308 | > 309 | {method} 310 | </Tag> 311 | </span> 312 | ), 313 | }, 314 | { 315 | title: "路径", 316 | dataIndex: "path", 317 | key: "path", 318 | width: 260, 319 | render: (path: string) => ( 320 | <code className="text-sm bg-gray-100 px-2 py-1 rounded">{path}</code> 321 | ), 322 | }, 323 | ], 324 | [] 325 | ); 326 | 327 | function extractOperationId(lines: string[], startIndex: number): string { 328 | const currentIndent = 329 | lines[startIndex].length - lines[startIndex].trimStart().length; 330 | for ( 331 | let i = startIndex + 1; 332 | i < Math.min(startIndex + 20, lines.length); 333 | i++ 334 | ) { 335 | const line = lines[i]; 336 | const trimmedLine = line.trim(); 337 | const lineIndent = line.length - line.trimStart().length; 338 | if (lineIndent <= currentIndent && trimmedLine !== "") break; 339 | if (trimmedLine.startsWith("operationId:")) { 340 | return trimmedLine.replace("operationId:", "").trim(); 341 | } 342 | } 343 | return ""; 344 | } 345 | 346 | return ( 347 | <div className="p-6 space-y-6"> 348 | <div className="flex justify-between items-center"> 349 | <div> 350 | <h1 className="text-2xl font-bold mb-2">API配置</h1> 351 | <p className="text-gray-600">查看API定义和规范</p> 352 | </div> 353 | </div> 354 | 355 | <Tabs 356 | defaultActiveKey="overview" 357 | items={[ 358 | { 359 | key: "overview", 360 | label: "API配置", 361 | children: ( 362 | <div className="space-y-4"> 363 | {isOpenApi && ( 364 | <> 365 | <Descriptions 366 | column={2} 367 | bordered 368 | size="small" 369 | className="mb-4" 370 | > 371 | {/* 'APIG_API' | 'HIGRESS' | 'APIG_AI' */} 372 | <Descriptions.Item label="API来源"> 373 | {SourceMap[apiProduct.apiConfig?.meta.source || '']} 374 | </Descriptions.Item> 375 | <Descriptions.Item label="API类型"> 376 | {apiProduct.apiConfig?.meta.type} 377 | </Descriptions.Item> 378 | </Descriptions> 379 | <Table 380 | columns={openApiColumns as any} 381 | dataSource={endpoints} 382 | rowKey="key" 383 | pagination={false} 384 | size="small" 385 | /> 386 | </> 387 | )} 388 | 389 | {isMcp && ( 390 | <> 391 | <Descriptions 392 | column={2} 393 | bordered 394 | size="small" 395 | className="mb-4" 396 | > 397 | <Descriptions.Item label="名称"> 398 | {mcpParsed.server?.name || 399 | apiProduct.mcpConfig?.meta.mcpServerName || 400 | "—"} 401 | </Descriptions.Item> 402 | <Descriptions.Item label="来源"> 403 | {apiProduct.mcpConfig?.meta.source 404 | ? SourceMap[apiProduct.mcpConfig.meta.source] || apiProduct.mcpConfig.meta.source 405 | : "—"} 406 | </Descriptions.Item> 407 | <Descriptions.Item label="来源类型"> 408 | {apiProduct.mcpConfig?.meta.fromType 409 | ? FromTypeMap[apiProduct.mcpConfig.meta.fromType] || apiProduct.mcpConfig.meta.fromType 410 | : "—"} 411 | </Descriptions.Item> 412 | <Descriptions.Item label="API类型"> 413 | {apiProduct.mcpConfig?.meta.source 414 | ? ProductTypeMap[apiProduct.type] || apiProduct.type 415 | : "—"} 416 | </Descriptions.Item> 417 | </Descriptions> 418 | <div className="mb-2"> 419 | <span className="font-bold mr-2">工具列表:</span> 420 | {/* {Array.isArray(mcpParsed.tools) && mcpParsed.tools.length > 0 ? ( 421 | mcpParsed.tools.map((tool, idx) => ( 422 | <Tag key={tool.name || idx} color="blue" className="mr-1"> 423 | {tool.name} 424 | </Tag> 425 | )) 426 | ) : ( 427 | <span className="text-gray-400">—</span> 428 | )} */} 429 | </div> 430 | 431 | <Collapse accordion> 432 | {(mcpParsed.tools || []).map((tool, idx) => ( 433 | <Collapse.Panel header={tool.name} key={idx}> 434 | {tool.description && ( 435 | <div className="mb-2 text-gray-600"> 436 | {tool.description} 437 | </div> 438 | )} 439 | <div className="mb-2 font-bold">输入参数:</div> 440 | <div className="space-y-2"> 441 | {tool.args && tool.args.length > 0 ? ( 442 | tool.args.map((arg, aidx) => ( 443 | <div key={aidx} className="flex flex-col mb-2"> 444 | <div className="flex items-center mb-1"> 445 | <span className="font-medium mr-2"> 446 | {arg.name} 447 | </span> 448 | {arg.type && ( 449 | <span className="text-xs text-gray-500 mr-2"> 450 | ({arg.type}) 451 | </span> 452 | )} 453 | {arg.required && ( 454 | <span className="text-red-500 text-xs"> 455 | * 456 | </span> 457 | )} 458 | </div> 459 | {arg.description && ( 460 | <div className="text-xs text-gray-500 mb-1"> 461 | {arg.description} 462 | </div> 463 | )} 464 | <input 465 | disabled 466 | className="border rounded px-2 py-1 text-sm bg-gray-100 w-full max-w-md" 467 | placeholder={ 468 | arg.defaultValue !== undefined && 469 | arg.defaultValue !== null 470 | ? String(arg.defaultValue) 471 | : "" 472 | } 473 | /> 474 | {Array.isArray(arg.enumValues) && 475 | arg.enumValues.length > 0 && ( 476 | <div className="text-xs text-gray-500 mt-1"> 477 | 可选值:{arg.enumValues.join(", ")} 478 | </div> 479 | )} 480 | </div> 481 | )) 482 | ) : ( 483 | <span className="text-gray-400">无参数</span> 484 | )} 485 | </div> 486 | </Collapse.Panel> 487 | ))} 488 | </Collapse> 489 | </> 490 | )} 491 | {!isOpenApi && !isMcp && ( 492 | <Card> 493 | <div className="text-center py-8 text-gray-500"> 494 | <p>暂无配置</p> 495 | </div> 496 | </Card> 497 | )} 498 | </div> 499 | ), 500 | }, 501 | ...(!isMcp ? [{ 502 | key: "source", 503 | label: "OpenAPI 规范", 504 | children: ( 505 | <div style={{ height: 460 }}> 506 | <MonacoEditor 507 | language="yaml" 508 | theme="vs-light" 509 | value={content} 510 | options={{ 511 | readOnly: true, 512 | minimap: { enabled: true }, 513 | scrollBeyondLastLine: false, 514 | scrollbar: { vertical: "visible", horizontal: "visible" }, 515 | wordWrap: "off", 516 | lineNumbers: "on", 517 | automaticLayout: true, 518 | fontSize: 14, 519 | copyWithSyntaxHighlighting: true, 520 | contextmenu: true, 521 | }} 522 | height="100%" 523 | /> 524 | </div> 525 | ), 526 | }] : []), 527 | ...(isMcp ? [{ 528 | key: "mcpServerConfig", 529 | label: "MCP连接配置", 530 | children: ( 531 | <div className="space-y-4"> 532 | <div className=""> 533 | {apiProduct.mcpConfig?.mcpServerConfig?.rawConfig ? ( 534 | // Local Mode - 显示本地配置 535 | <div> 536 | <h3 className="text-lg font-bold mb-2">Local Config</h3> 537 | <MonacoEditor 538 | language="json" 539 | theme="vs-light" 540 | value={localJson} 541 | options={{ 542 | readOnly: true, 543 | minimap: { enabled: true }, 544 | scrollBeyondLastLine: false, 545 | scrollbar: { vertical: "visible", horizontal: "visible" }, 546 | wordWrap: "off", 547 | lineNumbers: "on", 548 | automaticLayout: true, 549 | fontSize: 14, 550 | copyWithSyntaxHighlighting: true, 551 | contextmenu: true, 552 | }} 553 | height="150px" 554 | /> 555 | </div> 556 | ) : ( 557 | // HTTP/SSE Mode - 根据配置状态动态显示 558 | <> 559 | {httpJson && ( 560 | <div className="mt-4"> 561 | <h3 className="text-lg font-bold mb-2">HTTP Config</h3> 562 | <MonacoEditor 563 | language="json" 564 | theme="vs-light" 565 | value={httpJson} 566 | options={{ 567 | readOnly: true, 568 | minimap: { enabled: true }, 569 | scrollBeyondLastLine: false, 570 | scrollbar: { vertical: "visible", horizontal: "visible" }, 571 | wordWrap: "off", 572 | lineNumbers: "on", 573 | automaticLayout: true, 574 | fontSize: 14, 575 | copyWithSyntaxHighlighting: true, 576 | contextmenu: true, 577 | }} 578 | height="150px" 579 | /> 580 | </div> 581 | )} 582 | {sseJson && ( 583 | <div className="mt-4"> 584 | <h3 className="text-lg font-bold mb-2">SSE Config</h3> 585 | <MonacoEditor 586 | language="json" 587 | theme="vs-light" 588 | value={sseJson} 589 | options={{ 590 | readOnly: true, 591 | minimap: { enabled: true }, 592 | scrollBeyondLastLine: false, 593 | scrollbar: { vertical: "visible", horizontal: "visible" }, 594 | wordWrap: "off", 595 | lineNumbers: "on", 596 | automaticLayout: true, 597 | fontSize: 14, 598 | copyWithSyntaxHighlighting: true, 599 | contextmenu: true, 600 | }} 601 | height="150px" 602 | /> 603 | </div> 604 | )} 605 | </> 606 | )} 607 | </div> 608 | </div> 609 | ), 610 | }] : []) 611 | ]} 612 | /> 613 | </div> 614 | ); 615 | } 616 | ```