This is page 5 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/PortalSecurity.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import {Card, Form, Switch, Divider, message} from 'antd' 2 | import {useMemo} from 'react' 3 | import {Portal, ThirdPartyAuthConfig, AuthenticationType, OidcConfig, OAuth2Config} from '@/types' 4 | import {portalApi} from '@/lib/api' 5 | import {ThirdPartyAuthManager} from './ThirdPartyAuthManager' 6 | 7 | interface PortalSecurityProps { 8 | portal: Portal 9 | onRefresh?: () => void 10 | } 11 | 12 | export function PortalSecurity({portal, onRefresh}: PortalSecurityProps) { 13 | const [form] = Form.useForm() 14 | 15 | const handleSave = async () => { 16 | try { 17 | const values = await form.validateFields() 18 | 19 | await portalApi.updatePortal(portal.portalId, { 20 | name: portal.name, 21 | description: portal.description, 22 | portalSettingConfig: { 23 | ...portal.portalSettingConfig, 24 | builtinAuthEnabled: values.builtinAuthEnabled, 25 | oidcAuthEnabled: values.oidcAuthEnabled, 26 | autoApproveDevelopers: values.autoApproveDevelopers, 27 | autoApproveSubscriptions: values.autoApproveSubscriptions, 28 | frontendRedirectUrl: values.frontendRedirectUrl, 29 | }, 30 | portalDomainConfig: portal.portalDomainConfig, 31 | portalUiConfig: portal.portalUiConfig, 32 | }) 33 | 34 | message.success('安全设置保存成功') 35 | onRefresh?.() 36 | } catch (error) { 37 | message.error('保存安全设置失败') 38 | } 39 | } 40 | 41 | const handleSettingUpdate = () => { 42 | // 立即更新配置 43 | handleSave() 44 | } 45 | 46 | // 第三方认证配置保存函数 47 | const handleSaveThirdPartyAuth = async (configs: ThirdPartyAuthConfig[]) => { 48 | try { 49 | // 分离OIDC和OAuth2配置,去掉type字段 50 | const oidcConfigs = configs 51 | .filter(config => config.type === AuthenticationType.OIDC) 52 | .map(config => { 53 | const { type, ...oidcConfig } = config as (OidcConfig & { type: AuthenticationType.OIDC }) 54 | return oidcConfig 55 | }) 56 | 57 | const oauth2Configs = configs 58 | .filter(config => config.type === AuthenticationType.OAUTH2) 59 | .map(config => { 60 | const { type, ...oauth2Config } = config as (OAuth2Config & { type: AuthenticationType.OAUTH2 }) 61 | return oauth2Config 62 | }) 63 | 64 | const updateData = { 65 | ...portal, 66 | portalSettingConfig: { 67 | ...portal.portalSettingConfig, 68 | // 直接保存分离的配置数组 69 | oidcConfigs: oidcConfigs, 70 | oauth2Configs: oauth2Configs 71 | } 72 | } 73 | 74 | await portalApi.updatePortal(portal.portalId, updateData) 75 | 76 | onRefresh?.() 77 | } catch (error) { 78 | throw error 79 | } 80 | } 81 | 82 | // 合并OIDC和OAuth2配置用于统一显示 83 | const thirdPartyAuthConfigs = useMemo((): ThirdPartyAuthConfig[] => { 84 | const configs: ThirdPartyAuthConfig[] = [] 85 | 86 | // 添加OIDC配置 87 | if (portal.portalSettingConfig?.oidcConfigs) { 88 | portal.portalSettingConfig.oidcConfigs.forEach(oidcConfig => { 89 | configs.push({ 90 | ...oidcConfig, 91 | type: AuthenticationType.OIDC 92 | }) 93 | }) 94 | } 95 | 96 | // 添加OAuth2配置 97 | if (portal.portalSettingConfig?.oauth2Configs) { 98 | portal.portalSettingConfig.oauth2Configs.forEach(oauth2Config => { 99 | configs.push({ 100 | ...oauth2Config, 101 | type: AuthenticationType.OAUTH2 102 | }) 103 | }) 104 | } 105 | 106 | return configs 107 | }, [portal.portalSettingConfig?.oidcConfigs, portal.portalSettingConfig?.oauth2Configs]) 108 | 109 | 110 | return ( 111 | <div className="p-6 space-y-6"> 112 | <div> 113 | <h1 className="text-2xl font-bold mb-2">Portal安全配置</h1> 114 | <p className="text-gray-600">配置Portal的认证与审批方式</p> 115 | </div> 116 | 117 | <Form 118 | form={form} 119 | layout="vertical" 120 | initialValues={{ 121 | builtinAuthEnabled: portal.portalSettingConfig?.builtinAuthEnabled, 122 | oidcAuthEnabled: portal.portalSettingConfig?.oidcAuthEnabled, 123 | autoApproveDevelopers: portal.portalSettingConfig?.autoApproveDevelopers, 124 | autoApproveSubscriptions: portal.portalSettingConfig?.autoApproveSubscriptions, 125 | frontendRedirectUrl: portal.portalSettingConfig?.frontendRedirectUrl, 126 | }} 127 | > 128 | <Card> 129 | <div className="space-y-6"> 130 | {/* 基本安全配置标题 */} 131 | <h3 className="text-lg font-medium">基本安全配置</h3> 132 | 133 | {/* 基本安全设置内容 */} 134 | <div className="grid grid-cols-2 gap-6"> 135 | <Form.Item 136 | name="builtinAuthEnabled" 137 | label="账号密码登录" 138 | valuePropName="checked" 139 | > 140 | <Switch 141 | onChange={() => handleSettingUpdate()} 142 | /> 143 | </Form.Item> 144 | <Form.Item 145 | name="autoApproveDevelopers" 146 | label="开发者自动审批" 147 | valuePropName="checked" 148 | > 149 | <Switch 150 | onChange={() => handleSettingUpdate()} 151 | /> 152 | </Form.Item> 153 | <Form.Item 154 | name="autoApproveSubscriptions" 155 | label="订阅自动审批" 156 | valuePropName="checked" 157 | > 158 | <Switch 159 | onChange={() => handleSettingUpdate()} 160 | /> 161 | </Form.Item> 162 | </div> 163 | 164 | <Divider /> 165 | 166 | {/* 第三方认证管理器 - 内部已有标题,不需要重复添加 */} 167 | <ThirdPartyAuthManager 168 | configs={thirdPartyAuthConfigs} 169 | onSave={handleSaveThirdPartyAuth} 170 | /> 171 | </div> 172 | </Card> 173 | </Form> 174 | </div> 175 | ) 176 | } 177 | ``` -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- ``` 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!-- 3 | ~ Licensed to the Apache Software Foundation (ASF) under one 4 | ~ or more contributor license agreements. See the NOTICE file 5 | ~ distributed with this work for additional information 6 | ~ regarding copyright ownership. The ASF licenses this file 7 | ~ to you under the Apache License, Version 2.0 (the 8 | ~ "License"); you may not use this file except in compliance 9 | ~ with the License. You may obtain a copy of the License at 10 | ~ 11 | ~ http://www.apache.org/licenses/LICENSE-2.0 12 | ~ 13 | ~ Unless required by applicable law or agreed to in writing, 14 | ~ software distributed under the License is distributed on an 15 | ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | ~ KIND, either express or implied. See the License for the 17 | ~ specific language governing permissions and limitations 18 | ~ under the License. 19 | --> 20 | <project xmlns="http://maven.apache.org/POM/4.0.0" 21 | xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 22 | xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 23 | <modelVersion>4.0.0</modelVersion> 24 | 25 | <groupId>com.alibaba.himarket</groupId> 26 | <artifactId>himarket</artifactId> 27 | <version>1.0-SNAPSHOT</version> 28 | <packaging>pom</packaging> 29 | <name>himarket</name> 30 | <description>HiMarket AI OPEN Platform</description> 31 | <url>https://github.com/higress-group/himarket</url> 32 | 33 | <licenses> 34 | <license> 35 | <name>Apache License, Version 2.0</name> 36 | <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url> 37 | <distribution>repo</distribution> 38 | </license> 39 | </licenses> 40 | 41 | <modules> 42 | <module>portal-dal</module> 43 | <module>portal-server</module> 44 | <module>portal-bootstrap</module> 45 | </modules> 46 | 47 | <properties> 48 | <java.version>1.8</java.version> 49 | <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> 50 | <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> 51 | <spring-boot.version>2.7.18</spring-boot.version> 52 | <mybatis.version>2.3.1</mybatis.version> 53 | <mariadb.version>3.4.1</mariadb.version> 54 | <hutool.version>5.8.32</hutool.version> 55 | <bouncycastle.version>1.78</bouncycastle.version> 56 | <springdoc.version>1.7.0</springdoc.version> 57 | <apigsdk.version>4.0.10</apigsdk.version> 58 | <msesdk.version>7.21.0</msesdk.version> 59 | <aliyunsdk.version>4.4.6</aliyunsdk.version> 60 | <okhttp.version>4.12.0</okhttp.version> 61 | <maven.compiler.source>${java.version}</maven.compiler.source> 62 | <maven.compiler.target>${java.version}</maven.compiler.target> 63 | </properties> 64 | 65 | <!-- Dependency Management --> 66 | <dependencyManagement> 67 | <dependencies> 68 | <!-- Spring Boot Dependencies --> 69 | <dependency> 70 | <groupId>org.springframework.boot</groupId> 71 | <artifactId>spring-boot-dependencies</artifactId> 72 | <version>${spring-boot.version}</version> 73 | <type>pom</type> 74 | <scope>import</scope> 75 | </dependency> 76 | 77 | <!-- MariaDB Driver --> 78 | <dependency> 79 | <groupId>org.mariadb.jdbc</groupId> 80 | <artifactId>mariadb-java-client</artifactId> 81 | <version>${mariadb.version}</version> 82 | </dependency> 83 | 84 | <!-- Hutool --> 85 | <dependency> 86 | <groupId>cn.hutool</groupId> 87 | <artifactId>hutool-all</artifactId> 88 | <version>${hutool.version}</version> 89 | </dependency> 90 | 91 | <!-- Spring Boot Starter Security --> 92 | <dependency> 93 | <groupId>org.springframework.boot</groupId> 94 | <artifactId>spring-boot-starter-security</artifactId> 95 | <version>${spring-boot.version}</version> 96 | </dependency> 97 | 98 | <!-- Spring Boot Starter OAuth2 Client --> 99 | <dependency> 100 | <groupId>org.springframework.boot</groupId> 101 | <artifactId>spring-boot-starter-oauth2-client</artifactId> 102 | </dependency> 103 | 104 | <dependency> 105 | <groupId>org.springdoc</groupId> 106 | <artifactId>springdoc-openapi-ui</artifactId> 107 | <version>${springdoc.version}</version> 108 | </dependency> 109 | 110 | <dependency> 111 | <groupId>com.aliyun</groupId> 112 | <artifactId>alibabacloud-apig20240327</artifactId> 113 | <version>${apigsdk.version}</version> 114 | </dependency> 115 | 116 | <!-- 阿里云 MSE SDK --> 117 | <dependency> 118 | <groupId>com.aliyun</groupId> 119 | <artifactId>mse20190531</artifactId> 120 | <version>${msesdk.version}</version> 121 | </dependency> 122 | 123 | <dependency> 124 | <groupId>com.aliyun</groupId> 125 | <artifactId>aliyun-java-sdk-core</artifactId> 126 | <version>${aliyunsdk.version}</version> 127 | </dependency> 128 | 129 | <dependency> 130 | <groupId>com.squareup.okhttp3</groupId> 131 | <artifactId>okhttp</artifactId> 132 | <version>${okhttp.version}</version> 133 | </dependency> 134 | 135 | <dependency> 136 | <groupId>com.alibaba.nacos</groupId> 137 | <artifactId>nacos-maintainer-client</artifactId> 138 | <version>3.0.2</version> 139 | </dependency> 140 | 141 | <!-- Bouncy Castle Provider --> 142 | <dependency> 143 | <groupId>org.bouncycastle</groupId> 144 | <artifactId>bcprov-jdk15to18</artifactId> 145 | <version>${bouncycastle.version}</version> 146 | </dependency> 147 | </dependencies> 148 | </dependencyManagement> 149 | 150 | <!-- Build Configuration --> 151 | <build> 152 | <pluginManagement> 153 | <plugins> 154 | <plugin> 155 | <groupId>org.springframework.boot</groupId> 156 | <artifactId>spring-boot-maven-plugin</artifactId> 157 | <version>${spring-boot.version}</version> 158 | </plugin> 159 | <plugin> 160 | <groupId>org.apache.maven.plugins</groupId> 161 | <artifactId>maven-compiler-plugin</artifactId> 162 | <version>3.8.1</version> 163 | <configuration> 164 | <source>${java.version}</source> 165 | <target>${java.version}</target> 166 | <encoding>${project.build.sourceEncoding}</encoding> 167 | </configuration> 168 | </plugin> 169 | </plugins> 170 | </pluginManagement> 171 | </build> 172 | </project> ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/core/utils/TokenUtil.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.core.utils; 21 | 22 | import cn.hutool.core.map.MapUtil; 23 | import cn.hutool.core.util.ObjectUtil; 24 | import cn.hutool.core.util.StrUtil; 25 | import cn.hutool.extra.spring.SpringUtil; 26 | import cn.hutool.jwt.JWT; 27 | import cn.hutool.jwt.JWTUtil; 28 | import cn.hutool.jwt.signers.JWTSignerUtil; 29 | import com.alibaba.apiopenplatform.core.constant.CommonConstants; 30 | import com.alibaba.apiopenplatform.support.common.User; 31 | import com.alibaba.apiopenplatform.support.enums.UserType; 32 | 33 | import javax.servlet.http.Cookie; 34 | import javax.servlet.http.HttpServletRequest; 35 | import java.nio.charset.StandardCharsets; 36 | import java.time.Duration; 37 | import java.util.*; 38 | import java.util.concurrent.ConcurrentHashMap; 39 | 40 | public class TokenUtil { 41 | 42 | private static String JWT_SECRET; 43 | 44 | private static long JWT_EXPIRE_MILLIS; 45 | 46 | private static final Map<String, Long> INVALID_TOKENS = new ConcurrentHashMap<>(); 47 | 48 | private static String getJwtSecret() { 49 | if (JWT_SECRET == null) { 50 | JWT_SECRET = SpringUtil.getProperty("jwt.secret"); 51 | } 52 | 53 | if (StrUtil.isBlank(JWT_SECRET)) { 54 | throw new RuntimeException("JWT secret cannot be empty"); 55 | } 56 | return JWT_SECRET; 57 | } 58 | 59 | private static long getJwtExpireMillis() { 60 | if (JWT_EXPIRE_MILLIS == 0) { 61 | String expiration = SpringUtil.getProperty("jwt.expiration"); 62 | if (StrUtil.isBlank(expiration)) { 63 | throw new RuntimeException("JWT expiration is empty"); 64 | } 65 | 66 | if (expiration.matches("\\d+[smhd]")) { 67 | JWT_EXPIRE_MILLIS = Duration.parse("PT" + expiration.toUpperCase()).toMillis(); 68 | } else { 69 | JWT_EXPIRE_MILLIS = Long.parseLong(expiration); 70 | } 71 | } 72 | return JWT_EXPIRE_MILLIS; 73 | } 74 | 75 | public static String generateAdminToken(String userId) { 76 | return generateToken(UserType.ADMIN, userId); 77 | } 78 | 79 | public static String generateDeveloperToken(String userId) { 80 | return generateToken(UserType.DEVELOPER, userId); 81 | } 82 | 83 | /** 84 | * 生成令牌 85 | * 86 | * @param userType 87 | * @param userId 88 | * @return 89 | */ 90 | private static String generateToken(UserType userType, String userId) { 91 | long now = System.currentTimeMillis(); 92 | 93 | Map<String, String> claims = MapUtil.<String, String>builder() 94 | .put(CommonConstants.USER_TYPE, userType.name()) 95 | .put(CommonConstants.USER_ID, userId) 96 | .build(); 97 | 98 | return JWT.create() 99 | .addPayloads(claims) 100 | .setIssuedAt(new Date(now)) 101 | .setExpiresAt(new Date(now + getJwtExpireMillis())) 102 | .setSigner(JWTSignerUtil.hs256(getJwtSecret().getBytes(StandardCharsets.UTF_8))) 103 | .sign(); 104 | } 105 | 106 | /** 107 | * 解析Token 108 | * 109 | * @param token 110 | * @return 111 | */ 112 | public static User parseUser(String token) { 113 | JWT jwt = JWTUtil.parseToken(token); 114 | 115 | // 验证签名 116 | boolean isValid = jwt.setSigner(JWTSignerUtil.hs256(getJwtSecret().getBytes(StandardCharsets.UTF_8))).verify(); 117 | if (!isValid) { 118 | throw new IllegalArgumentException("Invalid token signature"); 119 | } 120 | 121 | // 验证过期时间 122 | Object expObj = jwt.getPayloads().get(JWT.EXPIRES_AT); 123 | if (ObjectUtil.isNotNull(expObj)) { 124 | long expireAt = Long.parseLong(expObj.toString()); 125 | if (expireAt * 1000 <= System.currentTimeMillis()) { 126 | throw new IllegalArgumentException("Token has expired"); 127 | } 128 | } 129 | 130 | return jwt.getPayloads().toBean(User.class); 131 | } 132 | 133 | public static String getTokenFromRequest(HttpServletRequest request) { 134 | // 从Header中获取token 135 | String authHeader = request.getHeader(CommonConstants.AUTHORIZATION_HEADER); 136 | 137 | String token = null; 138 | if (authHeader != null && authHeader.startsWith(CommonConstants.BEARER_PREFIX)) { 139 | token = authHeader.substring(CommonConstants.BEARER_PREFIX.length()); 140 | } 141 | 142 | // 从Cookie中获取token 143 | if (StrUtil.isBlank(token)) { 144 | token = Optional.ofNullable(request.getCookies()) 145 | .flatMap(cookies -> Arrays.stream(cookies) 146 | .filter(cookie -> CommonConstants.AUTH_TOKEN_COOKIE.equals(cookie.getName())) 147 | .map(Cookie::getValue) 148 | .findFirst()) 149 | .orElse(null); 150 | } 151 | if (StrUtil.isBlank(token) || isTokenRevoked(token)) { 152 | return null; 153 | } 154 | 155 | return token; 156 | } 157 | 158 | public static void revokeToken(String token) { 159 | if (StrUtil.isBlank(token)) { 160 | return; 161 | } 162 | long expireAt = getTokenExpireTime(token); 163 | INVALID_TOKENS.put(token, expireAt); 164 | cleanExpiredTokens(); 165 | } 166 | 167 | private static long getTokenExpireTime(String token) { 168 | JWT jwt = JWTUtil.parseToken(token); 169 | Object expObj = jwt.getPayloads().get(JWT.EXPIRES_AT); 170 | if (ObjectUtil.isNotNull(expObj)) { 171 | return Long.parseLong(expObj.toString()) * 1000; // JWT过期时间是秒,转换为毫秒 172 | } 173 | return System.currentTimeMillis() + getJwtExpireMillis(); // 默认过期时间 174 | } 175 | 176 | public static void revokeToken(HttpServletRequest request) { 177 | String token = getTokenFromRequest(request); 178 | if (StrUtil.isNotBlank(token)) { 179 | revokeToken(token); 180 | } 181 | } 182 | 183 | public static boolean isTokenRevoked(String token) { 184 | if (StrUtil.isBlank(token)) { 185 | return false; 186 | } 187 | Long expireAt = INVALID_TOKENS.get(token); 188 | if (expireAt == null) { 189 | return false; 190 | } 191 | if (expireAt <= System.currentTimeMillis()) { 192 | INVALID_TOKENS.remove(token); 193 | return false; 194 | } 195 | return true; 196 | } 197 | 198 | private static void cleanExpiredTokens() { 199 | long now = System.currentTimeMillis(); 200 | INVALID_TOKENS.entrySet().removeIf(entry -> entry.getValue() <= now); 201 | } 202 | 203 | public static long getTokenExpiresIn() { 204 | return getJwtExpireMillis() / 1000; 205 | } 206 | } 207 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/pages/Consumers.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Card, Table, Button, Space, Typography, Input, Avatar } from "antd"; 2 | import { SearchOutlined, DeleteOutlined, EyeOutlined } from "@ant-design/icons"; 3 | import { Layout } from "../components/Layout"; 4 | import { useEffect, useState, useCallback } from "react"; 5 | import { getConsumers, deleteConsumer, createConsumer } from "../lib/api"; 6 | import { message, Modal } from "antd"; 7 | import { Link, useSearchParams } from "react-router-dom"; 8 | import { formatDateTime } from "../lib/utils"; 9 | import type { Consumer } from "../types/consumer"; 10 | 11 | const { Title, Paragraph } = Typography; 12 | const { Search } = Input; 13 | 14 | function ConsumersPage() { 15 | const [searchParams] = useSearchParams(); 16 | const productId = searchParams.get('productId'); 17 | 18 | const [consumers, setConsumers] = useState<Consumer[]>([]); 19 | const [loading, setLoading] = useState(false); 20 | const [page, setPage] = useState(1); 21 | const [pageSize, setPageSize] = useState(10); 22 | const [total, setTotal] = useState(0); 23 | const [searchInput, setSearchInput] = useState(""); // 输入框的值 24 | const [searchName, setSearchName] = useState(""); // 实际搜索的值 25 | const [addModalOpen, setAddModalOpen] = useState(false); 26 | const [addLoading, setAddLoading] = useState(false); 27 | const [addForm, setAddForm] = useState({ name: '', description: '' }); 28 | 29 | const fetchConsumers = useCallback(async (searchKeyword?: string, targetPage?: number) => { 30 | setLoading(true); 31 | try { 32 | const res = await getConsumers( 33 | { name: searchKeyword || '' }, 34 | { page: targetPage || page, size: pageSize } 35 | ); 36 | setConsumers(res.data?.content || []); 37 | setTotal(res.data?.totalElements || 0); 38 | } catch { 39 | // message.error("获取消费者列表失败"); 40 | } finally { 41 | setLoading(false); 42 | } 43 | }, [page, pageSize]); // 不依赖 searchName 44 | 45 | // 初始加载和分页变化时调用 46 | useEffect(() => { 47 | fetchConsumers(searchName); 48 | }, [page, pageSize, fetchConsumers]); // 包含fetchConsumers以确保初始加载 49 | 50 | // 处理搜索 51 | const handleSearch = useCallback(async (searchValue?: string) => { 52 | const actualSearchValue = searchValue !== undefined ? searchValue : searchInput; 53 | setSearchName(actualSearchValue); 54 | setPage(1); 55 | // 直接调用API,不依赖状态变化 56 | await fetchConsumers(actualSearchValue, 1); 57 | }, [searchInput, fetchConsumers]); 58 | 59 | const handleDelete = (record: Consumer) => { 60 | Modal.confirm({ 61 | title: `确定要删除消费者「${record.name}」吗?`, 62 | onOk: async () => { 63 | try { 64 | await deleteConsumer(record.consumerId); 65 | message.success("删除成功"); 66 | await fetchConsumers(searchName); // 使用当前搜索条件重新加载 67 | } catch { 68 | // message.error("删除失败"); 69 | } 70 | }, 71 | }); 72 | }; 73 | 74 | const handleAdd = async () => { 75 | if (!addForm.name.trim()) { 76 | message.warning('请输入消费者名称'); 77 | return; 78 | } 79 | setAddLoading(true); 80 | try { 81 | await createConsumer({ name: addForm.name, description: addForm.description }); 82 | message.success('新增成功'); 83 | setAddModalOpen(false); 84 | setAddForm({ name: '', description: '' }); 85 | await fetchConsumers(searchName); // 使用当前搜索条件重新加载 86 | } catch { 87 | // message.error('新增失败'); 88 | } finally { 89 | setAddLoading(false); 90 | } 91 | }; 92 | 93 | const columns = [ 94 | { 95 | title: '消费者', 96 | dataIndex: 'name', 97 | key: 'name', 98 | render: (name: string, record: Consumer) => ( 99 | <div className="flex items-center space-x-3"> 100 | <Avatar className="bg-blue-500"> 101 | {name?.charAt(0).toUpperCase()} 102 | </Avatar> 103 | <div> 104 | <div className="font-medium">{name}</div> 105 | <div className="text-xs text-gray-400">{record.description}</div> 106 | </div> 107 | </div> 108 | ), 109 | }, 110 | { 111 | title: '创建时间', 112 | dataIndex: 'createAt', 113 | key: 'createAt', 114 | render: (date: string) => date ? formatDateTime(date) : '-', 115 | }, 116 | { 117 | title: '操作', 118 | key: 'action', 119 | render: (_: unknown, record: Consumer) => ( 120 | <Space> 121 | <Link to={`/consumers/${record.consumerId}`}> 122 | <Button 123 | type="link" 124 | icon={<EyeOutlined />} 125 | > 126 | 查看详情 127 | </Button> 128 | </Link> 129 | <Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}> 130 | 删除 131 | </Button> 132 | </Space> 133 | ), 134 | }, 135 | ]; 136 | 137 | return ( 138 | <Layout> 139 | <div className="mb-8"> 140 | <Title level={1} className="mb-2"> 141 | {productId ? '产品订阅管理' : '消费者管理'} 142 | </Title> 143 | <Paragraph className="text-gray-600"> 144 | {productId ? '管理此产品的消费者订阅情况' : '管理API的消费者用户和订阅信息'} 145 | </Paragraph> 146 | </div> 147 | 148 | <Card> 149 | <div className="mb-4 flex gap-4"> 150 | {!productId && ( 151 | <Button type="primary" onClick={() => setAddModalOpen(true)}> 152 | 新增消费者 153 | </Button> 154 | )} 155 | <Search 156 | placeholder={"搜索消费者..."} 157 | prefix={<SearchOutlined />} 158 | style={{ width: 300 }} 159 | value={searchInput} 160 | onChange={e => setSearchInput(e.target.value)} 161 | onSearch={handleSearch} 162 | allowClear 163 | /> 164 | </div> 165 | <Table 166 | columns={columns} 167 | dataSource={consumers} 168 | rowKey="consumerId" 169 | loading={loading} 170 | pagination={{ 171 | total, 172 | current: page, 173 | pageSize, 174 | showSizeChanger: true, 175 | showQuickJumper: true, 176 | showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`, 177 | onChange: (p, ps) => { 178 | setPage(p); 179 | setPageSize(ps); 180 | }, 181 | }} 182 | /> 183 | <Modal 184 | title="新增消费者" 185 | open={addModalOpen} 186 | onCancel={() => { setAddModalOpen(false); setAddForm({ name: '', description: '' }); }} 187 | onOk={handleAdd} 188 | confirmLoading={addLoading} 189 | okText="提交" 190 | cancelText="取消" 191 | > 192 | <div style={{ marginBottom: 16 }}> 193 | <Input 194 | placeholder="消费者名称" 195 | value={addForm.name} 196 | maxLength={50} 197 | onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))} 198 | disabled={addLoading} 199 | /> 200 | </div> 201 | <div> 202 | <Input.TextArea 203 | placeholder="描述(可选),长度限制64" 204 | value={addForm.description} 205 | maxLength={64} 206 | onChange={e => setAddForm(f => ({ ...f, description: e.target.value }))} 207 | disabled={addLoading} 208 | rows={3} 209 | /> 210 | </div> 211 | </Modal> 212 | </Card> 213 | 214 | <Card title="消费者统计" className="mt-8"> 215 | <div className="flex justify-center"> 216 | <div className="text-center"> 217 | <div className="text-2xl font-bold text-blue-600">{total}</div> 218 | <div className="text-sm text-gray-500">总消费者</div> 219 | </div> 220 | {/* 其他统计项可根据接口返回字段补充 */} 221 | </div> 222 | </Card> 223 | </Layout> 224 | ); 225 | } 226 | 227 | export default ConsumersPage; ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/pages/Mcp.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useEffect, useState } from "react"; 2 | import { Card, Tag, Typography, Input, Avatar, Skeleton } from "antd"; 3 | import { Link } from "react-router-dom"; 4 | import { Layout } from "../components/Layout"; 5 | import api from "../lib/api"; 6 | import { ProductStatus } from "../types"; 7 | import type { Product, ApiResponse, PaginatedResponse, ProductIcon } from "../types"; 8 | // import { getCategoryText, getCategoryColor } from "../lib/statusUtils"; 9 | 10 | const { Title, Paragraph } = Typography; 11 | const { Search } = Input; 12 | 13 | interface McpServer { 14 | key: string; 15 | name: string; 16 | description: string; 17 | status: string; 18 | version: string; 19 | endpoints: number; 20 | category: string; 21 | creator: string; 22 | icon?: ProductIcon; 23 | mcpConfig?: any; 24 | updatedAt: string; 25 | } 26 | 27 | function McpPage() { 28 | const [loading, setLoading] = useState(false); 29 | const [mcpServers, setMcpServers] = useState<McpServer[]>([]); 30 | const [searchText, setSearchText] = useState(''); 31 | 32 | useEffect(() => { 33 | fetchMcpServers(); 34 | }, []); 35 | // 处理产品图标的函数 36 | const getIconUrl = (icon?: ProductIcon | null): string => { 37 | const fallback = "/MCP.svg"; 38 | 39 | if (!icon) { 40 | return fallback; 41 | } 42 | 43 | switch (icon.type) { 44 | case "URL": 45 | return icon.value || fallback; 46 | case "BASE64": 47 | // 如果value已经包含data URL前缀,直接使用;否则添加前缀 48 | return icon.value ? (icon.value.startsWith('data:') ? icon.value : `data:image/png;base64,${icon.value}`) : fallback; 49 | default: 50 | return fallback; 51 | } 52 | }; 53 | const fetchMcpServers = async () => { 54 | setLoading(true); 55 | try { 56 | const response: ApiResponse<PaginatedResponse<Product>> = await api.get("/products?type=MCP_SERVER&page=0&size=100"); 57 | if (response.code === "SUCCESS" && response.data) { 58 | // 移除重复过滤,简化数据映射 59 | const mapped = response.data.content.map((item: Product) => ({ 60 | key: item.productId, 61 | name: item.name, 62 | description: item.description, 63 | status: item.status === ProductStatus.ENABLE ? 'active' : 'inactive', 64 | version: 'v1.0.0', 65 | endpoints: 0, 66 | category: item.category, 67 | creator: 'Unknown', 68 | icon: item.icon || undefined, 69 | mcpConfig: item.mcpConfig, 70 | updatedAt: item.updatedAt?.slice(0, 10) || '' 71 | })); 72 | setMcpServers(mapped); 73 | } 74 | } catch (error) { 75 | console.error('获取MCP服务器列表失败:', error); 76 | } finally { 77 | setLoading(false); 78 | } 79 | }; 80 | 81 | 82 | 83 | const filteredMcpServers = mcpServers.filter(server => { 84 | return server.name.toLowerCase().includes(searchText.toLowerCase()) || 85 | server.description.toLowerCase().includes(searchText.toLowerCase()) || 86 | server.creator.toLowerCase().includes(searchText.toLowerCase()); 87 | }); 88 | 89 | return ( 90 | <Layout> 91 | {/* Header Section */} 92 | <div className="text-center mb-8"> 93 | <Title level={1} className="mb-4"> 94 | MCP 市场 95 | </Title> 96 | <Paragraph className="text-gray-600 text-lg max-w-4xl mx-auto text-flow text-flow-grey slow"> 97 | 支持私有化部署,共建和兼容MCP市场官方协议,具备更多管理能力,支持自动注册、智能路由的MCP市场 98 | </Paragraph> 99 | </div> 100 | 101 | {/* Search Section */} 102 | <div className="flex justify-center mb-8"> 103 | <div className="relative w-full max-w-2xl"> 104 | <Search 105 | placeholder="请输入内容" 106 | size="large" 107 | value={searchText} 108 | onChange={(e) => setSearchText(e.target.value)} 109 | className="rounded-lg shadow-lg" 110 | /> 111 | </div> 112 | </div> 113 | 114 | {/* Servers Section */} 115 | <div className="mb-6"> 116 | <Title level={3} className="mb-4"> 117 | 热门/推荐 MCP Servers: {filteredMcpServers.length} 118 | </Title> 119 | </div> 120 | 121 | {/* Servers Grid */} 122 | {loading ? ( 123 | <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> 124 | {Array.from({ length: 6 }).map((_, index) => ( 125 | <Card key={index} className="h-full rounded-lg shadow-lg"> 126 | <Skeleton loading active> 127 | <div className="flex items-start space-x-4 mb-2"> 128 | <Skeleton.Avatar size={48} active /> 129 | <div className="flex-1 min-w-0"> 130 | <Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} /> 131 | <Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} /> 132 | <Skeleton.Input active size="small" style={{ width: '60%' }} /> 133 | </div> 134 | </div> 135 | </Skeleton> 136 | </Card> 137 | ))} 138 | </div> 139 | ) : ( 140 | <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> 141 | {filteredMcpServers.map((server) => ( 142 | <Link key={server.key} to={`/mcp/${server.key}`} className="block"> 143 | <Card 144 | hoverable 145 | className="h-full transition-all duration-200 hover:shadow-lg cursor-pointer rounded-lg shadow-lg" 146 | > 147 | <div className="flex items-start space-x-4 mb-2"> 148 | {/* Server Icon */} 149 | {server.icon ? ( 150 | <Avatar 151 | size={48} 152 | src={getIconUrl(server.icon)} 153 | /> 154 | ) : ( 155 | <Avatar 156 | size={48} 157 | className="bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg" 158 | style={{ fontSize: "18px", fontWeight: "600" }} 159 | > 160 | {server.name[0]} 161 | </Avatar> 162 | )} 163 | 164 | {/* Server Info */} 165 | <div className="flex-1 min-w-0"> 166 | <div className="flex items-center justify-between mb-2"> 167 | <Title level={5} className="mb-0 truncate"> 168 | {server.name} 169 | </Title> 170 | <Tag className="text-xs text-green-700 border-0 bg-transparent px-0"> 171 | {server.mcpConfig?.mcpServerConfig?.transportMode || 'remote'} 172 | </Tag> 173 | </div> 174 | </div> 175 | </div> 176 | <Paragraph className="text-sm text-gray-600 mb-3 line-clamp-2"> 177 | {server.description} 178 | </Paragraph> 179 | 180 | <div className="flex items-center justify-between"> 181 | {/* <Tag color={getCategoryColor(server.category || 'OFFICIAL')} className=""> 182 | {getCategoryText(server.category || 'OFFICIAL')} 183 | </Tag> */} 184 | <div className="text-xs text-gray-400"> 185 | 更新 {server.updatedAt} 186 | </div> 187 | </div> 188 | </Card> 189 | </Link> 190 | ))} 191 | </div> 192 | )} 193 | 194 | {/* Empty State */} 195 | {filteredMcpServers.length === 0 && ( 196 | <div className="text-center py-8"> 197 | <div className="text-gray-500">暂无MCP服务器</div> 198 | </div> 199 | )} 200 | </Layout> 201 | ); 202 | } 203 | 204 | export default McpPage; ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/lib/api.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios' 2 | import { getToken, removeToken } from './utils' 3 | import { message } from 'antd' 4 | 5 | 6 | 7 | const api: AxiosInstance = axios.create({ 8 | baseURL: import.meta.env.VITE_API_BASE_URL, 9 | timeout: 10000, 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | }, 13 | withCredentials: true, // 确保跨域请求时携带 cookie 14 | }) 15 | 16 | // 请求拦截器 17 | api.interceptors.request.use( 18 | (config: InternalAxiosRequestConfig) => { 19 | const token = getToken() 20 | if (token && config.headers) { 21 | config.headers.Authorization = `Bearer ${token}` 22 | } 23 | return config 24 | }, 25 | (error) => { 26 | return Promise.reject(error) 27 | } 28 | ) 29 | 30 | // 响应拦截器 31 | api.interceptors.response.use( 32 | (response: AxiosResponse) => { 33 | return response.data 34 | }, 35 | (error) => { 36 | message.error(error.response?.data?.message || '请求发生错误'); 37 | if (error.response?.status === 403 || error.response?.status === 401) { 38 | removeToken() 39 | window.location.href = '/login' 40 | } 41 | return Promise.reject(error) 42 | } 43 | ) 44 | 45 | 46 | export default api 47 | 48 | // 用户相关API 49 | export const authApi = { 50 | getNeedInit: () => { 51 | return api.get('/admins/need-init') 52 | } 53 | } 54 | // Portal相关API 55 | export const portalApi = { 56 | // 获取portal列表 57 | getPortals: (params?: { page?: number; size?: number }) => { 58 | return api.get(`/portals`, { params }) 59 | }, 60 | // 获取Portal Dashboard URL 61 | getPortalDashboard: (portalId: string, type: string = 'Portal') => { 62 | return api.get(`/portals/${portalId}/dashboard`, { params: { type } }) 63 | }, 64 | deletePortal: (portalId: string) => { 65 | return api.delete(`/portals/${portalId}`) 66 | }, 67 | createPortal: (data: any) => { 68 | return api.post(`/portals`, data) 69 | }, 70 | // 获取portal详情 71 | getPortalDetail: (portalId: string) => { 72 | return api.get(`/portals/${portalId}`) 73 | }, 74 | // 绑定域名 75 | bindDomain: (portalId: string, domainData: { domain: string; protocol: string; type: string }) => { 76 | return api.post(`/portals/${portalId}/domains`, domainData) 77 | }, 78 | // 解绑域名 79 | unbindDomain: (portalId: string, domain: string) => { 80 | const encodedDomain = encodeURIComponent(domain) 81 | return api.delete(`/portals/${portalId}/domains/${encodedDomain}`) 82 | }, 83 | // 更新Portal 84 | updatePortal: (portalId: string, data: any) => { 85 | return api.put(`/portals/${portalId}`, data) 86 | }, 87 | // 更新Portal设置 88 | updatePortalSettings: (portalId: string, settings: any) => { 89 | return api.put(`/portals/${portalId}/setting`, settings) 90 | }, 91 | // 获取Portal的开发者列表 92 | getDeveloperList: (portalId: string, pagination?: { page: number; size: number }) => { 93 | return api.get(`/developers`, { 94 | params: { 95 | portalId, 96 | ...pagination 97 | } 98 | }) 99 | }, 100 | // 更新开发者状态 101 | updateDeveloperStatus: (portalId: string, developerId: string, status: string) => { 102 | return api.patch(`/developers/${developerId}/status`, { 103 | portalId, 104 | status 105 | }) 106 | }, 107 | deleteDeveloper: (developerId: string) => { 108 | return api.delete(`/developers/${developerId}`) 109 | }, 110 | getConsumerList: (portalId: string, developerId: string, pagination?: { page: number; size: number }) => { 111 | return api.get(`/consumers`, { 112 | params: { 113 | portalId, 114 | developerId, 115 | ...pagination 116 | } 117 | }) 118 | }, 119 | // 审批consumer 120 | approveConsumer: (consumerId: string) => { 121 | return api.patch(`/consumers/${consumerId}/status`) 122 | }, 123 | // 获取Consumer的订阅列表 124 | getConsumerSubscriptions: (consumerId: string, params?: { page?: number; size?: number; status?: string }) => { 125 | return api.get(`/consumers/${consumerId}/subscriptions`, { params }) 126 | }, 127 | // 审批订阅申请 128 | approveSubscription: (consumerId: string, productId: string) => { 129 | return api.patch(`/consumers/${consumerId}/subscriptions/${productId}`) 130 | }, 131 | // 删除订阅 132 | deleteSubscription: (consumerId: string, productId: string) => { 133 | return api.delete(`/consumers/${consumerId}/subscriptions/${productId}`) 134 | } 135 | } 136 | 137 | // API Product相关API 138 | export const apiProductApi = { 139 | // 获取API产品列表 140 | getApiProducts: (params?: any) => { 141 | return api.get(`/products`, { params }) 142 | }, 143 | // 获取API产品详情 144 | getApiProductDetail: (productId: string) => { 145 | return api.get(`/products/${productId}`) 146 | }, 147 | // 创建API产品 148 | createApiProduct: (data: any) => { 149 | return api.post(`/products`, data) 150 | }, 151 | // 删除API产品 152 | deleteApiProduct: (productId: string) => { 153 | return api.delete(`/products/${productId}`) 154 | }, 155 | // 更新API产品 156 | updateApiProduct: (productId: string, data: any) => { 157 | return api.put(`/products/${productId}`, data) 158 | }, 159 | // 获取API产品关联的服务 160 | getApiProductRef: (productId: string) => { 161 | return api.get(`/products/${productId}/ref`) 162 | }, 163 | // 创建API产品关联 164 | createApiProductRef: (productId: string, data: any) => { 165 | return api.post(`/products/${productId}/ref`, data) 166 | }, 167 | // 删除API产品关联 168 | deleteApiProductRef: (productId: string) => { 169 | return api.delete(`/products/${productId}/ref`) 170 | }, 171 | // 获取API产品已发布的门户列表 172 | getApiProductPublications: (productId: string, params?: any) => { 173 | return api.get(`/products/${productId}/publications`, { params }) 174 | }, 175 | // 发布API产品到门户 176 | publishToPortal: (productId: string, portalId: string) => { 177 | return api.post(`/products/${productId}/publications/${portalId}`) 178 | }, 179 | // 取消发布API产品到门户 180 | cancelPublishToPortal: (productId: string, portalId: string) => { 181 | return api.delete(`/products/${productId}/publications/${portalId}`) 182 | }, 183 | // 获取API产品的Dashboard监控面板URL 184 | getProductDashboard: (productId: string) => { 185 | return api.get(`/products/${productId}/dashboard`) 186 | } 187 | } 188 | 189 | // Gateway相关API 190 | export const gatewayApi = { 191 | // 获取网关列表 192 | getGateways: (params?: any) => { 193 | return api.get(`/gateways`, { params }) 194 | }, 195 | // 获取APIG网关 196 | getApigGateway: (data: any) => { 197 | return api.get(`/gateways/apig`, { params: { 198 | ...data, 199 | } }) 200 | }, 201 | // 获取ADP网关 202 | getAdpGateways: (data: any) => { 203 | return api.post(`/gateways/adp`, data) 204 | }, 205 | // 删除网关 206 | deleteGateway: (gatewayId: string) => { 207 | return api.delete(`/gateways/${gatewayId}`) 208 | }, 209 | // 导入网关 210 | importGateway: (data: any) => { 211 | return api.post(`/gateways`, { ...data }) 212 | }, 213 | // 获取网关的REST API列表 214 | getGatewayRestApis: (gatewayId: string, data: any) => { 215 | return api.get(`/gateways/${gatewayId}/rest-apis`, { 216 | params: data 217 | }) 218 | }, 219 | // 获取网关的MCP Server列表 220 | getGatewayMcpServers: (gatewayId: string, data: any) => { 221 | return api.get(`/gateways/${gatewayId}/mcp-servers`, { 222 | params: data 223 | }) 224 | }, 225 | // 获取网关的Dashboard URL 226 | getDashboard: (gatewayId: string) => { 227 | return api.get(`/gateways/${gatewayId}/dashboard`) 228 | } 229 | } 230 | 231 | export const nacosApi = { 232 | getNacos: (params?: any) => { 233 | return api.get(`/nacos`, { params }) 234 | }, 235 | // 从阿里云 MSE 获取 Nacos 集群列表 236 | getMseNacos: (params: { regionId: string; accessKey: string; secretKey: string; page?: number; size?: number }) => { 237 | return api.get(`/nacos/mse`, { params }) 238 | }, 239 | createNacos: (data: any) => { 240 | return api.post(`/nacos`, data) 241 | }, 242 | deleteNacos: (nacosId: string) => { 243 | return api.delete(`/nacos/${nacosId}`) 244 | }, 245 | updateNacos: (nacosId: string, data: any) => { 246 | return api.put(`/nacos/${nacosId}`, data) 247 | }, 248 | getNacosMcpServers: (nacosId: string, data: any) => { 249 | return api.get(`/nacos/${nacosId}/mcp-servers`, { 250 | params: data 251 | }) 252 | }, 253 | // 获取指定 Nacos 实例的命名空间列表 254 | getNamespaces: (nacosId: string, params?: { page?: number; size?: number }) => { 255 | return api.get(`/nacos/${nacosId}/namespaces`, { params }) 256 | } 257 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/portal/PortalPublishedApis.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState, useEffect } from 'react' 2 | import { Card, Table, Modal, Form, Button, Space, Select, message, Checkbox } from 'antd' 3 | import { EyeOutlined, DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons' 4 | import { Portal, ApiProduct } from '@/types' 5 | import { apiProductApi } from '@/lib/api' 6 | import { useNavigate } from 'react-router-dom' 7 | import { ProductTypeMap } from '@/lib/utils' 8 | 9 | interface PortalApiProductsProps { 10 | portal: Portal 11 | } 12 | 13 | export function PortalPublishedApis({ portal }: PortalApiProductsProps) { 14 | const navigate = useNavigate() 15 | const [apiProducts, setApiProducts] = useState<ApiProduct[]>([]) 16 | const [apiProductsOptions, setApiProductsOptions] = useState<ApiProduct[]>([]) 17 | const [isModalVisible, setIsModalVisible] = useState(false) 18 | const [selectedApiIds, setSelectedApiIds] = useState<string[]>([]) 19 | const [loading, setLoading] = useState(false) 20 | const [modalLoading, setModalLoading] = useState(false) 21 | 22 | // 分页状态 23 | const [currentPage, setCurrentPage] = useState(1) 24 | const [pageSize, setPageSize] = useState(10) 25 | const [total, setTotal] = useState(0) 26 | 27 | const [form] = Form.useForm() 28 | 29 | useEffect(() => { 30 | if (portal.portalId) { 31 | fetchApiProducts() 32 | } 33 | }, [portal.portalId, currentPage, pageSize]) 34 | 35 | const fetchApiProducts = () => { 36 | setLoading(true) 37 | apiProductApi.getApiProducts({ 38 | portalId: portal.portalId, 39 | page: currentPage, 40 | size: pageSize 41 | }).then((res) => { 42 | setApiProducts(res.data.content) 43 | setTotal(res.data.totalElements || 0) 44 | }).finally(() => { 45 | setLoading(false) 46 | }) 47 | } 48 | 49 | useEffect(() => { 50 | if (isModalVisible) { 51 | setModalLoading(true) 52 | apiProductApi.getApiProducts({ 53 | page: 1, 54 | size: 500, // 获取所有可用的API 55 | status: 'READY' 56 | }).then((res) => { 57 | // 过滤掉已发布在该门户里的api 58 | setApiProductsOptions(res.data.content.filter((api: ApiProduct) => 59 | !apiProducts.some((a: ApiProduct) => a.productId === api.productId) 60 | )) 61 | }).finally(() => { 62 | setModalLoading(false) 63 | }) 64 | } 65 | }, [isModalVisible]) // 移除apiProducts依赖,避免重复请求 66 | 67 | const handlePageChange = (page: number, size?: number) => { 68 | setCurrentPage(page) 69 | if (size) { 70 | setPageSize(size) 71 | } 72 | } 73 | 74 | const columns = [ 75 | { 76 | title: '名称/ID', 77 | key: 'nameAndId', 78 | width: 280, 79 | render: (_: any, record: ApiProduct) => ( 80 | <div> 81 | <div className="text-sm font-medium text-gray-900 truncate">{record.name}</div> 82 | <div className="text-xs text-gray-500 truncate">{record.productId}</div> 83 | </div> 84 | ), 85 | }, 86 | { 87 | title: '类型', 88 | dataIndex: 'type', 89 | key: 'type', 90 | width: 120, 91 | render: (text: string) => ProductTypeMap[text] || text 92 | }, 93 | { 94 | title: '描述', 95 | dataIndex: 'description', 96 | key: 'description', 97 | width: 400, 98 | }, 99 | // { 100 | // title: '分类', 101 | // dataIndex: 'category', 102 | // key: 'category', 103 | // }, 104 | { 105 | title: '操作', 106 | key: 'action', 107 | width: 180, 108 | render: (_: any, record: ApiProduct) => ( 109 | <Space size="middle"> 110 | <Button 111 | onClick={() => { 112 | navigate(`/api-products/detail?productId=${record.productId}`) 113 | }} 114 | type="link" icon={<EyeOutlined />}> 115 | 查看 116 | </Button> 117 | 118 | <Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.productId, record.name)}> 119 | 移除 120 | </Button> 121 | </Space> 122 | ), 123 | }, 124 | ] 125 | 126 | const modalColumns = [ 127 | { 128 | title: '选择', 129 | dataIndex: 'select', 130 | key: 'select', 131 | width: 60, 132 | render: (_: any, record: ApiProduct) => ( 133 | <Checkbox 134 | checked={selectedApiIds.includes(record.productId)} 135 | onChange={(e) => { 136 | if (e.target.checked) { 137 | setSelectedApiIds([...selectedApiIds, record.productId]) 138 | } else { 139 | setSelectedApiIds(selectedApiIds.filter(id => id !== record.productId)) 140 | } 141 | }} 142 | /> 143 | ), 144 | }, 145 | { 146 | title: '名称', 147 | dataIndex: 'name', 148 | key: 'name', 149 | width: 320, 150 | render: (_: any, record: ApiProduct) => ( 151 | <div> 152 | <div className="text-sm font-medium text-gray-900 truncate"> 153 | {record.name} 154 | </div> 155 | <div className="text-xs text-gray-500 truncate"> 156 | {record.productId} 157 | </div> 158 | </div> 159 | ), 160 | }, 161 | { 162 | title: '类型', 163 | dataIndex: 'type', 164 | key: 'type', 165 | width: 100, 166 | render: (type: string) => ProductTypeMap[type] || type, 167 | }, 168 | { 169 | title: '描述', 170 | dataIndex: 'description', 171 | key: 'description', 172 | width: 300, 173 | }, 174 | ] 175 | 176 | const handleDelete = (productId: string, productName: string) => { 177 | Modal.confirm({ 178 | title: '确认移除', 179 | icon: <ExclamationCircleOutlined />, 180 | content: `确定要从门户中移除API产品 "${productName}" 吗?此操作不可恢复。`, 181 | okText: '确认移除', 182 | okType: 'danger', 183 | cancelText: '取消', 184 | onOk() { 185 | apiProductApi.cancelPublishToPortal(productId, portal.portalId).then((res) => { 186 | message.success('移除成功') 187 | fetchApiProducts() 188 | setIsModalVisible(false) 189 | }).catch((error) => { 190 | // message.error('移除失败') 191 | }) 192 | }, 193 | }) 194 | } 195 | 196 | const handlePublish = async () => { 197 | if (selectedApiIds.length === 0) { 198 | message.warning('请至少选择一个API') 199 | return 200 | } 201 | 202 | setModalLoading(true) 203 | try { 204 | // 批量发布选中的API 205 | for (const productId of selectedApiIds) { 206 | await apiProductApi.publishToPortal(productId, portal.portalId) 207 | } 208 | message.success(`成功发布 ${selectedApiIds.length} 个API`) 209 | setSelectedApiIds([]) 210 | fetchApiProducts() 211 | setIsModalVisible(false) 212 | } catch (error) { 213 | // message.error('发布失败') 214 | } finally { 215 | setModalLoading(false) 216 | } 217 | } 218 | 219 | const handleModalCancel = () => { 220 | setIsModalVisible(false) 221 | setSelectedApiIds([]) 222 | } 223 | 224 | return ( 225 | <div className="p-6 space-y-6"> 226 | <div className="flex justify-between items-center"> 227 | <div> 228 | <h1 className="text-2xl font-bold mb-2">API Product</h1> 229 | <p className="text-gray-600">管理在此Portal中发布的API产品</p> 230 | </div> 231 | <Button type="primary" onClick={() => setIsModalVisible(true)}> 232 | 发布新API 233 | </Button> 234 | </div> 235 | 236 | <Card> 237 | <Table 238 | columns={columns} 239 | dataSource={apiProducts} 240 | rowKey="productId" 241 | loading={loading} 242 | pagination={{ 243 | current: currentPage, 244 | pageSize: pageSize, 245 | total: total, 246 | showSizeChanger: true, 247 | showQuickJumper: true, 248 | showTotal: (total) => `共 ${total} 条`, 249 | onChange: handlePageChange, 250 | onShowSizeChange: handlePageChange, 251 | }} 252 | /> 253 | </Card> 254 | 255 | <Modal 256 | title="发布API产品" 257 | open={isModalVisible} 258 | onOk={handlePublish} 259 | onCancel={handleModalCancel} 260 | okText="发布" 261 | cancelText="取消" 262 | width={800} 263 | confirmLoading={modalLoading} 264 | > 265 | <Table 266 | columns={modalColumns} 267 | dataSource={apiProductsOptions} 268 | rowKey="productId" 269 | loading={modalLoading} 270 | pagination={false} 271 | scroll={{ y: 400 }} 272 | /> 273 | </Modal> 274 | </div> 275 | ) 276 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/pages/Apis.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useEffect, useState } from "react"; 2 | import { Card, Tag, Typography, Input, Avatar, Skeleton } from "antd"; 3 | import { Link } from "react-router-dom"; 4 | import { Layout } from "../components/Layout"; 5 | import api from "../lib/api"; 6 | import { ProductStatus } from "../types"; 7 | import type { Product, ApiResponse, PaginatedResponse, ProductIcon } from "../types"; 8 | // import { getCategoryText, getCategoryColor } from "../lib/statusUtils"; 9 | import './Test.css'; 10 | 11 | const { Title, Paragraph } = Typography; 12 | const { Search } = Input; 13 | 14 | 15 | 16 | interface ApiProductListItem { 17 | key: string; 18 | name: string; 19 | description: string; 20 | status: string; 21 | version: string; 22 | endpoints: number; 23 | category: string; 24 | creator: string; 25 | icon?: ProductIcon; 26 | updatedAt: string; 27 | } 28 | 29 | function APIsPage() { 30 | const [loading, setLoading] = useState(false); 31 | const [apiProducts, setApiProducts] = useState<ApiProductListItem[]>([]); 32 | const [searchText, setSearchText] = useState(''); 33 | 34 | useEffect(() => { 35 | fetchApiProducts(); 36 | }, []); 37 | 38 | // 处理产品图标的函数 39 | const getIconUrl = (icon?: ProductIcon | null): string => { 40 | const fallback = "/logo.svg"; 41 | 42 | if (!icon) { 43 | return fallback; 44 | } 45 | 46 | switch (icon.type) { 47 | case "URL": 48 | return icon.value || fallback; 49 | case "BASE64": 50 | // 如果value已经包含data URL前缀,直接使用;否则添加前缀 51 | return icon.value ? (icon.value.startsWith('data:') ? icon.value : `data:image/png;base64,${icon.value}`) : fallback; 52 | default: 53 | return fallback; 54 | } 55 | }; 56 | const fetchApiProducts = async () => { 57 | setLoading(true); 58 | try { 59 | const response: ApiResponse<PaginatedResponse<Product>> = await api.get("/products?type=REST_API&page=0&size=100"); 60 | if (response.code === "SUCCESS" && response.data) { 61 | // 移除重复过滤,简化数据映射 62 | const mapped = response.data.content.map((item: Product) => ({ 63 | key: item.productId, 64 | name: item.name, 65 | description: item.description, 66 | status: item.status === ProductStatus.ENABLE ? 'active' : 'inactive', 67 | version: 'v1.0.0', 68 | endpoints: 0, 69 | category: item.category, 70 | creator: 'Unknown', 71 | icon: item.icon || undefined, 72 | updatedAt: item.updatedAt?.slice(0, 10) || '' 73 | })); 74 | setApiProducts(mapped); 75 | } 76 | } catch (error) { 77 | console.error('获取API产品列表失败:', error); 78 | } finally { 79 | setLoading(false); 80 | } 81 | }; 82 | 83 | 84 | 85 | const filteredApiProducts = apiProducts.filter(product => { 86 | return product.name.toLowerCase().includes(searchText.toLowerCase()) || 87 | product.description.toLowerCase().includes(searchText.toLowerCase()) || 88 | product.creator.toLowerCase().includes(searchText.toLowerCase()); 89 | }); 90 | 91 | const getApiIcon = (name: string) => { 92 | // Generate initials for API icon 93 | const words = name.split(' '); 94 | if (words.length >= 2) { 95 | return words[0][0] + words[1][0]; 96 | } 97 | return name.substring(0, 2).toUpperCase(); 98 | }; 99 | 100 | const getApiIconColor = (name: string) => { 101 | const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2']; 102 | const index = name.charCodeAt(0) % colors.length; 103 | return colors[index]; 104 | }; 105 | 106 | return ( 107 | <Layout> 108 | {/* Header Section */} 109 | <div className="text-center mb-8"> 110 | <Title level={1} className="mb-4"> 111 | API 市场 112 | </Title> 113 | <Paragraph className="text-gray-600 text-lg max-w-4xl mx-auto text-flow text-flow-grey slow"> 114 | 支持私有化部署,具备更多管理能力,支持自动注册、智能路由的API市场 115 | </Paragraph> 116 | </div> 117 | 118 | {/* Search Section */} 119 | <div className="flex justify-center mb-8"> 120 | <div className="relative w-full max-w-2xl"> 121 | <Search 122 | placeholder="请输入内容" 123 | size="large" 124 | value={searchText} 125 | onChange={(e) => setSearchText(e.target.value)} 126 | className="rounded-lg shadow-lg" 127 | /> 128 | </div> 129 | </div> 130 | 131 | {/* APIs Section */} 132 | <div className="mb-6"> 133 | <Title level={3} className="mb-4"> 134 | 热门/推荐 APIs: {filteredApiProducts.length} 135 | </Title> 136 | </div> 137 | 138 | {/* APIs Grid */} 139 | {loading ? ( 140 | <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> 141 | {Array.from({ length: 6 }).map((_, index) => ( 142 | <Card key={index} className="h-full rounded-lg shadow-lg"> 143 | <Skeleton loading active> 144 | <div className="flex items-start space-x-4"> 145 | <Skeleton.Avatar size={48} active /> 146 | <div className="flex-1 min-w-0"> 147 | <Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} /> 148 | <Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} /> 149 | <Skeleton.Input active size="small" style={{ width: '60%' }} /> 150 | </div> 151 | </div> 152 | </Skeleton> 153 | </Card> 154 | ))} 155 | </div> 156 | ) : ( 157 | <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> 158 | {filteredApiProducts.map((product) => ( 159 | <Link key={product.key} to={`/apis/${product.key}`} className="block"> 160 | <Card 161 | hoverable 162 | className="h-full transition-all duration-200 hover:shadow-lg cursor-pointer rounded-lg shadow-lg" 163 | > 164 | <div className="flex items-start space-x-4"> 165 | {/* API Icon */} 166 | <Avatar 167 | size={48} 168 | src={product.icon ? getIconUrl(product.icon) : undefined} 169 | style={{ 170 | backgroundColor: getApiIconColor(product.name), 171 | fontSize: '18px', 172 | fontWeight: 'bold' 173 | }} 174 | > 175 | {!product.icon && getApiIcon(product.name)} 176 | </Avatar> 177 | 178 | {/* API Info */} 179 | <div className="flex-1 min-w-0"> 180 | <div className="flex items-center justify-between mb-2"> 181 | <Title level={5} className="mb-0 truncate"> 182 | {product.name} 183 | </Title> 184 | <Tag className="text-xs text-green-700 border-0 bg-transparent px-0"> 185 | REST 186 | </Tag> 187 | </div> 188 | 189 | {/* <div className="text-sm text-gray-500 mb-2"> 190 | 创建者: {product.creator} 191 | </div> */} 192 | 193 | <Paragraph className="text-sm text-gray-600 mb-3 line-clamp-2"> 194 | {product.description} 195 | </Paragraph> 196 | 197 | <div className="flex items-center justify-between"> 198 | {/* <Tag color={getCategoryColor(product.category)} className=""> 199 | {getCategoryText(product.category)} 200 | </Tag> */} 201 | <div className="text-xs text-gray-400"> 202 | 更新 {product.updatedAt} 203 | </div> 204 | </div> 205 | </div> 206 | </div> 207 | </Card> 208 | </Link> 209 | ))} 210 | </div> 211 | )} 212 | 213 | {/* Empty State */} 214 | {filteredApiProducts.length === 0 && ( 215 | <div className="text-center py-8"> 216 | <div className="text-gray-500">暂无API产品</div> 217 | </div> 218 | )} 219 | </Layout> 220 | ); 221 | } 222 | 223 | export default APIsPage; ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductPolicy.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Card, Button, Table, Tag, Space, Modal, Form, Input, Select, Switch, message } from 'antd' 2 | import { PlusOutlined, EditOutlined, DeleteOutlined, SettingOutlined, ExclamationCircleOutlined } from '@ant-design/icons' 3 | import { useState } from 'react' 4 | import type { ApiProduct } from '@/types/api-product'; 5 | import { formatDateTime } from '@/lib/utils' 6 | 7 | interface ApiProductPolicyProps { 8 | apiProduct: ApiProduct 9 | } 10 | 11 | interface Policy { 12 | id: string 13 | name: string 14 | type: string 15 | status: string 16 | description: string 17 | createdAt: string 18 | config: any 19 | } 20 | 21 | const mockPolicies: Policy[] = [ 22 | { 23 | id: "1", 24 | name: "Rate Limiting", 25 | type: "rate-limiting", 26 | status: "enabled", 27 | description: "限制API调用频率", 28 | createdAt: "2025-01-01T10:00:00Z", 29 | config: { 30 | minute: 100, 31 | hour: 1000 32 | } 33 | }, 34 | { 35 | id: "2", 36 | name: "Authentication", 37 | type: "key-auth", 38 | status: "enabled", 39 | description: "API密钥认证", 40 | createdAt: "2025-01-02T11:00:00Z", 41 | config: { 42 | key_names: ["apikey"], 43 | hide_credentials: true 44 | } 45 | }, 46 | { 47 | id: "3", 48 | name: "CORS", 49 | type: "cors", 50 | status: "enabled", 51 | description: "跨域资源共享", 52 | createdAt: "2025-01-03T12:00:00Z", 53 | config: { 54 | origins: ["*"], 55 | methods: ["GET", "POST", "PUT", "DELETE"] 56 | } 57 | } 58 | ] 59 | 60 | export function ApiProductPolicy({ apiProduct }: ApiProductPolicyProps) { 61 | const [policies, setPolicies] = useState<Policy[]>(mockPolicies) 62 | const [isModalVisible, setIsModalVisible] = useState(false) 63 | const [editingPolicy, setEditingPolicy] = useState<Policy | null>(null) 64 | const [form] = Form.useForm() 65 | 66 | const columns = [ 67 | { 68 | title: '策略名称', 69 | dataIndex: 'name', 70 | key: 'name', 71 | }, 72 | { 73 | title: '类型', 74 | dataIndex: 'type', 75 | key: 'type', 76 | render: (type: string) => { 77 | const typeMap: { [key: string]: string } = { 78 | 'rate-limiting': '限流', 79 | 'key-auth': '认证', 80 | 'cors': 'CORS', 81 | 'acl': '访问控制' 82 | } 83 | return <Tag color="blue">{typeMap[type] || type}</Tag> 84 | } 85 | }, 86 | { 87 | title: '状态', 88 | dataIndex: 'status', 89 | key: 'status', 90 | render: (status: string) => ( 91 | <Tag color={status === 'enabled' ? 'green' : 'red'}> 92 | {status === 'enabled' ? '启用' : '禁用'} 93 | </Tag> 94 | ) 95 | }, 96 | { 97 | title: '描述', 98 | dataIndex: 'description', 99 | key: 'description', 100 | ellipsis: true, 101 | }, 102 | { 103 | title: '创建时间', 104 | dataIndex: 'createdAt', 105 | key: 'createdAt', 106 | render: (date: string) => formatDateTime(date) 107 | }, 108 | { 109 | title: '操作', 110 | key: 'action', 111 | render: (_: any, record: Policy) => ( 112 | <Space size="middle"> 113 | <Button type="link" icon={<SettingOutlined />}> 114 | 配置 115 | </Button> 116 | <Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}> 117 | 编辑 118 | </Button> 119 | <Button 120 | type="link" 121 | danger 122 | icon={<DeleteOutlined />} 123 | onClick={() => handleDelete(record.id, record.name)} 124 | > 125 | 删除 126 | </Button> 127 | </Space> 128 | ), 129 | }, 130 | ] 131 | 132 | const handleAdd = () => { 133 | setEditingPolicy(null) 134 | setIsModalVisible(true) 135 | } 136 | 137 | const handleEdit = (policy: Policy) => { 138 | setEditingPolicy(policy) 139 | form.setFieldsValue({ 140 | name: policy.name, 141 | type: policy.type, 142 | description: policy.description, 143 | status: policy.status 144 | }) 145 | setIsModalVisible(true) 146 | } 147 | 148 | const handleDelete = (id: string, policyName: string) => { 149 | Modal.confirm({ 150 | title: '确认删除', 151 | icon: <ExclamationCircleOutlined />, 152 | content: `确定要删除策略 "${policyName}" 吗?此操作不可恢复。`, 153 | okText: '确认删除', 154 | okType: 'danger', 155 | cancelText: '取消', 156 | onOk() { 157 | setPolicies(policies.filter(policy => policy.id !== id)) 158 | message.success('策略删除成功') 159 | }, 160 | }) 161 | } 162 | 163 | const handleModalOk = () => { 164 | form.validateFields().then((values) => { 165 | if (editingPolicy) { 166 | // 编辑现有策略 167 | setPolicies(policies.map(policy => 168 | policy.id === editingPolicy.id 169 | ? { ...policy, ...values } 170 | : policy 171 | )) 172 | } else { 173 | // 添加新策略 174 | const newPolicy: Policy = { 175 | id: Date.now().toString(), 176 | name: values.name, 177 | type: values.type, 178 | status: values.status, 179 | description: values.description, 180 | createdAt: new Date().toISOString(), 181 | config: {} 182 | } 183 | setPolicies([...policies, newPolicy]) 184 | } 185 | setIsModalVisible(false) 186 | form.resetFields() 187 | setEditingPolicy(null) 188 | }) 189 | } 190 | 191 | const handleModalCancel = () => { 192 | setIsModalVisible(false) 193 | form.resetFields() 194 | setEditingPolicy(null) 195 | } 196 | 197 | return ( 198 | <div className="p-6 space-y-6"> 199 | <div className="flex justify-between items-center"> 200 | <div> 201 | <h1 className="text-2xl font-bold mb-2">策略管理</h1> 202 | <p className="text-gray-600">管理API产品的策略配置</p> 203 | </div> 204 | <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}> 205 | 添加策略 206 | </Button> 207 | </div> 208 | 209 | <Card> 210 | <Table 211 | columns={columns} 212 | dataSource={policies} 213 | rowKey="id" 214 | pagination={false} 215 | /> 216 | </Card> 217 | 218 | <Card title="策略设置"> 219 | <div className="space-y-4"> 220 | <div className="flex justify-between items-center"> 221 | <span>策略继承</span> 222 | <Switch defaultChecked /> 223 | </div> 224 | <div className="flex justify-between items-center"> 225 | <span>策略优先级</span> 226 | <Switch defaultChecked /> 227 | </div> 228 | <div className="flex justify-between items-center"> 229 | <span>策略日志</span> 230 | <Switch defaultChecked /> 231 | </div> 232 | </div> 233 | </Card> 234 | 235 | <Modal 236 | title={editingPolicy ? "编辑策略" : "添加策略"} 237 | open={isModalVisible} 238 | onOk={handleModalOk} 239 | onCancel={handleModalCancel} 240 | okText={editingPolicy ? "更新" : "添加"} 241 | cancelText="取消" 242 | > 243 | <Form form={form} layout="vertical"> 244 | <Form.Item 245 | name="name" 246 | label="策略名称" 247 | rules={[{ required: true, message: '请输入策略名称' }]} 248 | > 249 | <Input placeholder="请输入策略名称" /> 250 | </Form.Item> 251 | <Form.Item 252 | name="type" 253 | label="策略类型" 254 | rules={[{ required: true, message: '请选择策略类型' }]} 255 | > 256 | <Select placeholder="请选择策略类型"> 257 | <Select.Option value="rate-limiting">限流</Select.Option> 258 | <Select.Option value="key-auth">认证</Select.Option> 259 | <Select.Option value="cors">CORS</Select.Option> 260 | <Select.Option value="acl">访问控制</Select.Option> 261 | </Select> 262 | </Form.Item> 263 | <Form.Item 264 | name="description" 265 | label="描述" 266 | rules={[{ required: true, message: '请输入策略描述' }]} 267 | > 268 | <Input.TextArea placeholder="请输入策略描述" rows={3} /> 269 | </Form.Item> 270 | <Form.Item 271 | name="status" 272 | label="状态" 273 | rules={[{ required: true, message: '请选择状态' }]} 274 | > 275 | <Select placeholder="请选择状态"> 276 | <Select.Option value="enabled">启用</Select.Option> 277 | <Select.Option value="disabled">禁用</Select.Option> 278 | </Select> 279 | </Form.Item> 280 | </Form> 281 | </Modal> 282 | </div> 283 | ) 284 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/pages/ApiProductDetail.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState, useEffect } from 'react' 2 | import { useNavigate, useSearchParams } from 'react-router-dom' 3 | import { Button, Dropdown, MenuProps, Modal, message } from 'antd' 4 | import { 5 | MoreOutlined, 6 | LeftOutlined, 7 | EyeOutlined, 8 | LinkOutlined, 9 | BookOutlined, 10 | GlobalOutlined, 11 | DashboardOutlined 12 | } from '@ant-design/icons' 13 | import { ApiProductOverview } from '@/components/api-product/ApiProductOverview' 14 | import { ApiProductLinkApi } from '@/components/api-product/ApiProductLinkApi' 15 | import { ApiProductUsageGuide } from '@/components/api-product/ApiProductUsageGuide' 16 | import { ApiProductPortal } from '@/components/api-product/ApiProductPortal' 17 | import { ApiProductDashboard } from '@/components/api-product/ApiProductDashboard' 18 | import { apiProductApi } from '@/lib/api'; 19 | import type { ApiProduct, LinkedService } from '@/types/api-product'; 20 | 21 | import ApiProductFormModal from '@/components/api-product/ApiProductFormModal'; 22 | 23 | const menuItems = [ 24 | { 25 | key: "overview", 26 | label: "Overview", 27 | description: "产品概览", 28 | icon: EyeOutlined 29 | }, 30 | { 31 | key: "link-api", 32 | label: "Link API", 33 | description: "API关联", 34 | icon: LinkOutlined 35 | }, 36 | { 37 | key: "usage-guide", 38 | label: "Usage Guide", 39 | description: "使用指南", 40 | icon: BookOutlined 41 | }, 42 | { 43 | key: "portal", 44 | label: "Portal", 45 | description: "发布的门户", 46 | icon: GlobalOutlined 47 | }, 48 | { 49 | key: "dashboard", 50 | label: "Dashboard", 51 | description: "实时监控和统计", 52 | icon: DashboardOutlined 53 | } 54 | ] 55 | 56 | export default function ApiProductDetail() { 57 | const navigate = useNavigate() 58 | const [searchParams, setSearchParams] = useSearchParams() 59 | const [apiProduct, setApiProduct] = useState<ApiProduct | null>(null) 60 | const [linkedService, setLinkedService] = useState<LinkedService | null>(null) 61 | const [loading, setLoading] = useState(true) // 添加 loading 状态 62 | 63 | // 从URL query参数获取当前tab,默认为overview 64 | const currentTab = searchParams.get('tab') || 'overview' 65 | // 验证tab值是否有效,如果无效则使用默认值 66 | const validTab = menuItems.some(item => item.key === currentTab) ? currentTab : 'overview' 67 | const [activeTab, setActiveTab] = useState(validTab) 68 | 69 | const [editModalVisible, setEditModalVisible] = useState(false) 70 | 71 | 72 | const fetchApiProduct = async () => { 73 | const productId = searchParams.get('productId') 74 | if (productId) { 75 | setLoading(true) 76 | try { 77 | // 并行获取Product详情和关联信息 78 | const [productRes, refRes] = await Promise.all([ 79 | apiProductApi.getApiProductDetail(productId), 80 | apiProductApi.getApiProductRef(productId).catch(() => ({ data: null })) // 关联信息获取失败不影响页面显示 81 | ]) 82 | 83 | setApiProduct(productRes.data) 84 | setLinkedService(refRes.data || null) 85 | } catch (error) { 86 | console.error('获取Product详情失败:', error) 87 | } finally { 88 | setLoading(false) 89 | } 90 | } 91 | } 92 | 93 | // 更新关联信息的回调函数 94 | const handleLinkedServiceUpdate = (newLinkedService: LinkedService | null) => { 95 | setLinkedService(newLinkedService) 96 | } 97 | 98 | useEffect(() => { 99 | fetchApiProduct() 100 | }, [searchParams.get('productId')]) 101 | 102 | // 同步URL参数和activeTab状态 103 | useEffect(() => { 104 | setActiveTab(validTab) 105 | }, [validTab, searchParams]) 106 | 107 | const handleBackToApiProducts = () => { 108 | navigate('/api-products') 109 | } 110 | 111 | const handleTabChange = (tabKey: string) => { 112 | setActiveTab(tabKey) 113 | // 更新URL query参数 114 | const newSearchParams = new URLSearchParams(searchParams) 115 | newSearchParams.set('tab', tabKey) 116 | setSearchParams(newSearchParams) 117 | } 118 | 119 | const renderContent = () => { 120 | if (!apiProduct) { 121 | return <div className="p-6">Loading...</div> 122 | } 123 | 124 | switch (activeTab) { 125 | case "overview": 126 | return <ApiProductOverview apiProduct={apiProduct} linkedService={linkedService} onEdit={handleEdit} /> 127 | case "link-api": 128 | return <ApiProductLinkApi 129 | apiProduct={apiProduct} 130 | linkedService={linkedService} 131 | onLinkedServiceUpdate={handleLinkedServiceUpdate} 132 | handleRefresh={fetchApiProduct} 133 | /> 134 | case "usage-guide": 135 | return <ApiProductUsageGuide apiProduct={apiProduct} handleRefresh={fetchApiProduct} /> 136 | case "portal": 137 | return <ApiProductPortal apiProduct={apiProduct} /> 138 | case "dashboard": 139 | return <ApiProductDashboard apiProduct={apiProduct} /> 140 | default: 141 | return <ApiProductOverview apiProduct={apiProduct} linkedService={linkedService} onEdit={handleEdit} /> 142 | } 143 | } 144 | 145 | const dropdownItems: MenuProps['items'] = [ 146 | { 147 | key: 'delete', 148 | label: '删除', 149 | onClick: () => { 150 | Modal.confirm({ 151 | title: '确认删除', 152 | content: '确定要删除该产品吗?', 153 | onOk: () => { 154 | handleDeleteApiProduct() 155 | }, 156 | }) 157 | }, 158 | danger: true, 159 | }, 160 | ] 161 | 162 | const handleDeleteApiProduct = () => { 163 | if (!apiProduct) return; 164 | 165 | apiProductApi.deleteApiProduct(apiProduct.productId).then(() => { 166 | message.success('删除成功') 167 | navigate('/api-products') 168 | }).catch((error) => { 169 | // message.error(error.response?.data?.message || '删除失败') 170 | }) 171 | } 172 | 173 | const handleEdit = () => { 174 | setEditModalVisible(true) 175 | } 176 | 177 | const handleEditSuccess = () => { 178 | setEditModalVisible(false) 179 | fetchApiProduct() 180 | } 181 | 182 | const handleEditCancel = () => { 183 | setEditModalVisible(false) 184 | } 185 | 186 | return ( 187 | <div className="flex h-full w-full overflow-hidden"> 188 | {/* API Product 详情侧边栏 */} 189 | <div className="w-64 border-r bg-white flex flex-col flex-shrink-0"> 190 | {/* 返回按钮 */} 191 | <div className="pb-4 border-b"> 192 | <Button 193 | type="text" 194 | // className="w-full justify-start" 195 | onClick={handleBackToApiProducts} 196 | icon={<LeftOutlined />} 197 | > 198 | 返回 199 | </Button> 200 | </div> 201 | 202 | {/* API Product 信息 */} 203 | <div className="p-4 border-b"> 204 | <div className="flex items-center justify-between mb-2"> 205 | <h2 className="text-lg font-semibold">{apiProduct?.name || 'Loading...'}</h2> 206 | <Dropdown menu={{ items: dropdownItems }} trigger={['click']}> 207 | <Button type="text" icon={<MoreOutlined />} /> 208 | </Dropdown> 209 | </div> 210 | </div> 211 | 212 | {/* 导航菜单 */} 213 | <nav className="flex-1 p-4 space-y-1"> 214 | {menuItems.map((item) => { 215 | const Icon = item.icon; 216 | return ( 217 | <button 218 | key={item.key} 219 | onClick={() => handleTabChange(item.key)} 220 | className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${ 221 | activeTab === item.key 222 | ? "bg-blue-500 text-white" 223 | : "hover:bg-gray-100" 224 | }`} 225 | > 226 | <Icon className="h-4 w-4 flex-shrink-0" /> 227 | <div> 228 | <div className="font-medium">{item.label}</div> 229 | <div className="text-xs opacity-70">{item.description}</div> 230 | </div> 231 | </button> 232 | ); 233 | })} 234 | </nav> 235 | </div> 236 | 237 | {/* 主内容区域 */} 238 | <div className="flex-1 overflow-auto min-w-0"> 239 | <div className="w-full max-w-full"> 240 | {renderContent()} 241 | </div> 242 | </div> 243 | 244 | {apiProduct && ( 245 | <ApiProductFormModal 246 | visible={editModalVisible} 247 | onCancel={handleEditCancel} 248 | onSuccess={handleEditSuccess} 249 | productId={apiProduct.productId} 250 | initialData={apiProduct} 251 | /> 252 | )} 253 | </div> 254 | ) 255 | } 256 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/pages/PortalDetail.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState, useEffect } from 'react' 2 | import { useNavigate, useSearchParams } from 'react-router-dom' 3 | import { Button, Dropdown, MenuProps, Typography, Spin, Modal, message } from 'antd' 4 | import { 5 | MoreOutlined, 6 | LeftOutlined, 7 | EyeOutlined, 8 | ApiOutlined, 9 | TeamOutlined, 10 | SafetyOutlined, 11 | CloudOutlined, 12 | DashboardOutlined 13 | } from '@ant-design/icons' 14 | import { PortalOverview } from '@/components/portal/PortalOverview' 15 | import { PortalPublishedApis } from '@/components/portal/PortalPublishedApis' 16 | import { PortalDevelopers } from '@/components/portal/PortalDevelopers' 17 | import { PortalConsumers } from '@/components/portal/PortalConsumers' 18 | import { PortalDashboard } from '@/components/portal/PortalDashboard' 19 | import { PortalSecurity } from '@/components/portal/PortalSecurity' 20 | import { PortalDomain } from '@/components/portal/PortalDomain' 21 | import PortalFormModal from '@/components/portal/PortalFormModal' 22 | import { portalApi } from '@/lib/api' 23 | import { Portal } from '@/types' 24 | 25 | const { Title } = Typography 26 | 27 | // 移除mockPortal,使用真实API数据 28 | 29 | const menuItems = [ 30 | { 31 | key: "overview", 32 | label: "Overview", 33 | icon: EyeOutlined, 34 | description: "Portal概览" 35 | }, 36 | { 37 | key: "published-apis", 38 | label: "Products", 39 | icon: ApiOutlined, 40 | description: "已发布的API产品" 41 | }, 42 | { 43 | key: "developers", 44 | label: "Developers", 45 | icon: TeamOutlined, 46 | description: "开发者管理" 47 | }, 48 | { 49 | key: "security", 50 | label: "Security", 51 | icon: SafetyOutlined, 52 | description: "安全设置" 53 | }, 54 | { 55 | key: "domain", 56 | label: "Domain", 57 | icon: CloudOutlined, 58 | description: "域名管理" 59 | }, 60 | // { 61 | // key: "consumers", 62 | // label: "Consumers", 63 | // icon: UserOutlined, 64 | // description: "消费者管理" 65 | // }, 66 | { 67 | key: "dashboard", 68 | label: "Dashboard", 69 | icon: DashboardOutlined, 70 | description: "监控面板" 71 | } 72 | ] 73 | 74 | export default function PortalDetail() { 75 | const navigate = useNavigate() 76 | const [searchParams, setSearchParams] = useSearchParams() 77 | const [portal, setPortal] = useState<Portal | null>(null) 78 | const [loading, setLoading] = useState(true) // 初始状态为 loading 79 | const [error, setError] = useState<string | null>(null) 80 | const [editModalVisible, setEditModalVisible] = useState(false) 81 | 82 | // 从URL查询参数获取当前tab,默认为overview 83 | const currentTab = searchParams.get('tab') || 'overview' 84 | const [activeTab, setActiveTab] = useState(currentTab) 85 | 86 | const fetchPortalData = async () => { 87 | try { 88 | setLoading(true) 89 | const portalId = searchParams.get('id') || 'portal-6882e06f4fd0c963020e3485' 90 | const response = await portalApi.getPortalDetail(portalId) as any 91 | if (response && response.code === 'SUCCESS') { 92 | setPortal(response.data) 93 | } else { 94 | setError(response?.message || '获取Portal信息失败') 95 | } 96 | } catch (err) { 97 | console.error('获取Portal信息失败:', err) 98 | setError('获取Portal信息失败') 99 | } finally { 100 | setLoading(false) 101 | } 102 | } 103 | 104 | useEffect(() => { 105 | fetchPortalData() 106 | }, []) 107 | 108 | // 当URL中的tab参数变化时,更新activeTab 109 | useEffect(() => { 110 | setActiveTab(currentTab) 111 | }, [currentTab]) 112 | 113 | const handleBackToPortals = () => { 114 | navigate('/portals') 115 | } 116 | 117 | // 处理tab切换,同时更新URL查询参数 118 | const handleTabChange = (tabKey: string) => { 119 | setActiveTab(tabKey) 120 | const newSearchParams = new URLSearchParams(searchParams) 121 | newSearchParams.set('tab', tabKey) 122 | setSearchParams(newSearchParams) 123 | } 124 | 125 | const handleEdit = () => { 126 | setEditModalVisible(true) 127 | } 128 | 129 | const handleEditSuccess = () => { 130 | setEditModalVisible(false) 131 | fetchPortalData() 132 | } 133 | 134 | const handleEditCancel = () => { 135 | setEditModalVisible(false) 136 | } 137 | 138 | const renderContent = () => { 139 | if (!portal) return null 140 | 141 | switch (activeTab) { 142 | case "overview": 143 | return <PortalOverview portal={portal} onEdit={handleEdit} /> 144 | case "published-apis": 145 | return <PortalPublishedApis portal={portal} /> 146 | case "developers": 147 | return <PortalDevelopers portal={portal} /> 148 | case "security": 149 | return <PortalSecurity portal={portal} onRefresh={fetchPortalData} /> 150 | case "domain": 151 | return <PortalDomain portal={portal} onRefresh={fetchPortalData} /> 152 | case "consumers": 153 | return <PortalConsumers portal={portal} /> 154 | case "dashboard": 155 | return <PortalDashboard portal={portal} /> 156 | default: 157 | return <PortalOverview portal={portal} onEdit={handleEdit} /> 158 | } 159 | } 160 | 161 | const dropdownItems: MenuProps['items'] = [ 162 | { 163 | key: "delete", 164 | label: "删除", 165 | danger: true, 166 | onClick: () => { 167 | Modal.confirm({ 168 | title: "删除Portal", 169 | content: "确定要删除该Portal吗?", 170 | onOk: () => { 171 | return handleDeletePortal(); 172 | }, 173 | }); 174 | }, 175 | }, 176 | ] 177 | const handleDeletePortal = () => { 178 | return portalApi.deletePortal(searchParams.get('id') || '').then(() => { 179 | message.success('删除成功') 180 | navigate('/portals') 181 | }).catch((error) => { 182 | message.error(error?.response?.data?.message || '删除失败,请稍后重试') 183 | throw error; // 重新抛出错误,让Modal保持loading状态 184 | }) 185 | } 186 | 187 | if (error || !portal) { 188 | return ( 189 | <div className="flex h-full items-center justify-center"> 190 | <div className="text-center"> 191 | {error && <><p className=" mb-4">{error || 'Portal信息不存在'}</p> 192 | <Button onClick={() => navigate('/portals')}>返回Portal列表</Button></>} 193 | {!error && <Spin fullscreen spinning={loading} />} 194 | </div> 195 | </div> 196 | ) 197 | } 198 | 199 | return ( 200 | <div className="flex h-full"> 201 | <Spin fullscreen spinning={loading} /> 202 | {/* Portal详情侧边栏 */} 203 | <div className="w-64 border-r bg-white flex flex-col"> 204 | {/* 返回按钮 */} 205 | <div className="pb-4 border-b"> 206 | <Button 207 | type="text" 208 | // className="w-full justify-start text-gray-600 hover:text-gray-900" 209 | onClick={handleBackToPortals} 210 | icon={<LeftOutlined />} 211 | > 212 | 返回 213 | </Button> 214 | </div> 215 | 216 | {/* Portal 信息 */} 217 | <div className="p-4 border-b"> 218 | <div className="flex items-center justify-between mb-2"> 219 | <Title level={5} className="mb-0">{portal.name}</Title> 220 | <Dropdown menu={{ items: dropdownItems }} trigger={['click']}> 221 | <Button type="text" icon={<MoreOutlined />} size="small" /> 222 | </Dropdown> 223 | </div> 224 | </div> 225 | 226 | {/* 导航菜单 */} 227 | <nav className="flex-1 p-4 space-y-2"> 228 | {menuItems.map((item) => { 229 | const Icon = item.icon 230 | return ( 231 | <button 232 | key={item.key} 233 | onClick={() => handleTabChange(item.key)} 234 | className={`w-full flex items-center gap-3 px-3 py-3 rounded-lg text-left transition-colors ${ 235 | activeTab === item.key 236 | ? "bg-blue-50 text-blue-600 border border-blue-200" 237 | : "hover:bg-gray-50 text-gray-700" 238 | }`} 239 | > 240 | <Icon className="h-4 w-4 flex-shrink-0" /> 241 | <div className="flex-1"> 242 | <div className="font-medium">{item.label}</div> 243 | <div className="text-xs text-gray-500 mt-1">{item.description}</div> 244 | </div> 245 | </button> 246 | ) 247 | })} 248 | </nav> 249 | </div> 250 | 251 | {/* 主内容区域 */} 252 | <div className="flex-1 overflow-auto"> 253 | {renderContent()} 254 | </div> 255 | 256 | {portal && ( 257 | <PortalFormModal 258 | visible={editModalVisible} 259 | onCancel={handleEditCancel} 260 | onSuccess={handleEditSuccess} 261 | portal={portal} 262 | /> 263 | )} 264 | </div> 265 | ) 266 | } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/dto/converter/NacosToGatewayToolsConverter.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.dto.converter; 21 | 22 | import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo; 23 | import com.fasterxml.jackson.databind.ObjectMapper; 24 | import com.fasterxml.jackson.databind.JsonNode; 25 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; 26 | import lombok.Data; 27 | 28 | import java.util.ArrayList; 29 | import java.util.HashMap; 30 | import java.util.List; 31 | import java.util.Map; 32 | import java.util.Objects; 33 | import java.util.stream.Collectors; 34 | 35 | @Data 36 | public class NacosToGatewayToolsConverter { 37 | 38 | private Server server = new Server(); 39 | private List<Tool> tools = new ArrayList<>(); 40 | private List<String> allowTools = new ArrayList<>(); 41 | 42 | public void convertFromNacos(McpServerDetailInfo nacosDetail) { 43 | server.setName(nacosDetail.getName()); 44 | server.getConfig().put("apiKey", "your-api-key-here"); 45 | allowTools.add(nacosDetail.getName()); 46 | 47 | if (nacosDetail.getToolSpec() != null) { 48 | convertTools(nacosDetail.getToolSpec()); 49 | } 50 | } 51 | 52 | public String toYaml() { 53 | try { 54 | ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); 55 | return yamlMapper.writeValueAsString(this); 56 | } catch (Exception e) { 57 | throw new RuntimeException("Failed to convert to YAML", e); 58 | } 59 | } 60 | 61 | private void convertTools(Object toolSpec) { 62 | try { 63 | ObjectMapper jsonMapper = new ObjectMapper(); 64 | String toolSpecJson = jsonMapper.writeValueAsString(toolSpec); 65 | JsonNode toolSpecNode = jsonMapper.readTree(toolSpecJson); 66 | 67 | if (toolSpecNode.isArray()) { 68 | for (JsonNode toolNode : toolSpecNode) { 69 | Tool tool = convertToolNode(toolNode); 70 | if (tool != null) { 71 | tools.add(tool); 72 | } 73 | } 74 | } else if (toolSpecNode.has("tools") && toolSpecNode.get("tools").isArray()) { 75 | JsonNode toolsNode = toolSpecNode.get("tools"); 76 | for (JsonNode toolNode : toolsNode) { 77 | Tool tool = convertToolNode(toolNode); 78 | if (tool != null) { 79 | tools.add(tool); 80 | } 81 | } 82 | } 83 | } catch (Exception e) { 84 | // 转换失败时,tools保持空列表 85 | } 86 | } 87 | 88 | private Tool convertToolNode(JsonNode toolNode) { 89 | Tool result = new Tool(); 90 | result.setName(getStringValue(toolNode, "name")); 91 | result.setDescription(getStringValue(toolNode, "description")); 92 | 93 | if (result.getName() == null) { 94 | return null; 95 | } 96 | 97 | List<Arg> args = convertArgs(toolNode); 98 | result.setArgs(args); 99 | result.setRequestTemplate(buildDefaultRequestTemplate(result.getName())); 100 | result.setResponseTemplate(buildDefaultResponseTemplate()); 101 | 102 | return result; 103 | } 104 | 105 | private List<Arg> convertArgs(JsonNode toolNode) { 106 | List<Arg> args = new ArrayList<>(); 107 | 108 | try { 109 | if (toolNode.has("inputSchema") && toolNode.get("inputSchema").has("properties")) { 110 | JsonNode properties = toolNode.get("inputSchema").get("properties"); 111 | properties.fields().forEachRemaining(entry -> { 112 | String argName = entry.getKey(); 113 | JsonNode argNode = entry.getValue(); 114 | 115 | Arg arg = new Arg(); 116 | arg.setName(argName); 117 | arg.setDescription(getStringValue(argNode, "description")); 118 | arg.setType(getStringValue(argNode, "type")); 119 | arg.setRequired(getBooleanValue(argNode, "required", false)); 120 | arg.setPosition("query"); 121 | 122 | args.add(arg); 123 | }); 124 | } else if (toolNode.has("args") && toolNode.get("args").isArray()) { 125 | JsonNode argsNode = toolNode.get("args"); 126 | for (JsonNode argNode : argsNode) { 127 | Arg arg = new Arg(); 128 | arg.setName(getStringValue(argNode, "name")); 129 | arg.setDescription(getStringValue(argNode, "description")); 130 | arg.setType(getStringValue(argNode, "type")); 131 | arg.setRequired(getBooleanValue(argNode, "required", false)); 132 | arg.setPosition(getStringValue(argNode, "position")); 133 | arg.setDefaultValue(getStringValue(argNode, "default")); 134 | 135 | args.add(arg); 136 | } 137 | } 138 | } catch (Exception e) { 139 | // 转换失败时,args保持空列表 140 | } 141 | 142 | return args; 143 | } 144 | 145 | private RequestTemplate buildDefaultRequestTemplate(String toolName) { 146 | RequestTemplate template = new RequestTemplate(); 147 | template.setUrl("https://api.example.com/v1/" + toolName); 148 | template.setMethod("GET"); 149 | 150 | Header header = new Header(); 151 | header.setKey("Content-Type"); 152 | header.setValue("application/json"); 153 | template.getHeaders().add(header); 154 | 155 | return template; 156 | } 157 | 158 | private ResponseTemplate buildDefaultResponseTemplate() { 159 | ResponseTemplate template = new ResponseTemplate(); 160 | template.setBody(""); 161 | return template; 162 | } 163 | 164 | private String getStringValue(JsonNode node, String fieldName) { 165 | return node.has(fieldName) && !node.get(fieldName).isNull() ? 166 | node.get(fieldName).asText() : null; 167 | } 168 | 169 | private boolean getBooleanValue(JsonNode node, String fieldName, boolean defaultValue) { 170 | return node.has(fieldName) && !node.get(fieldName).isNull() ? 171 | node.get(fieldName).asBoolean() : defaultValue; 172 | } 173 | 174 | @Data 175 | public static class Server { 176 | private String name; 177 | private Map<String, Object> config = new HashMap<>(); 178 | } 179 | 180 | @Data 181 | public static class Tool { 182 | private String name; 183 | private String description; 184 | private List<Arg> args = new ArrayList<>(); 185 | private RequestTemplate requestTemplate; 186 | private ResponseTemplate responseTemplate; 187 | } 188 | 189 | @Data 190 | public static class Arg { 191 | private String name; 192 | private String description; 193 | private String type; 194 | private boolean required; 195 | private String position; 196 | private String defaultValue; 197 | private List<String> enumValues; 198 | } 199 | 200 | @Data 201 | public static class RequestTemplate { 202 | private String url; 203 | private String method; 204 | private List<Header> headers = new ArrayList<>(); 205 | } 206 | 207 | @Data 208 | public static class ResponseTemplate { 209 | private String body; 210 | } 211 | 212 | @Data 213 | public static class Header { 214 | private String key; 215 | private String value; 216 | } 217 | } 218 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/SwaggerUIWrapper.css: -------------------------------------------------------------------------------- ```css 1 | /* Swagger UI 自定义样式 */ 2 | .swagger-ui-wrapper { 3 | /* 隐藏顶部的信息栏,因为我们已经在上层显示了 */ 4 | .swagger-ui .info { 5 | display: none; 6 | } 7 | 8 | /* 完全隐藏服务器选择器的容器样式 */ 9 | .swagger-ui .scheme-container { 10 | padding: 0; 11 | background: transparent; 12 | border: none; 13 | margin-bottom: 16px; 14 | position: relative; 15 | box-shadow: none; 16 | } 17 | 18 | /* 隐藏服务器区域的所有边框和背景 */ 19 | .swagger-ui .scheme-container > div { 20 | background: transparent !important; 21 | border: none !important; 22 | box-shadow: none !important; 23 | padding: 0 !important; 24 | } 25 | 26 | /* 服务器URL样式优化 */ 27 | .swagger-ui .servers-title { 28 | font-weight: 600; 29 | margin-bottom: 8px; 30 | color: #262626; 31 | } 32 | 33 | .swagger-ui .servers select { 34 | font-family: Monaco, Consolas, monospace; 35 | background: white; 36 | border: 1px solid #d9d9d9; 37 | border-radius: 4px; 38 | padding: 8px 40px 8px 12px; 39 | font-size: 14px; 40 | color: #1890ff; 41 | cursor: pointer; 42 | min-width: 300px; 43 | position: relative; 44 | } 45 | 46 | .swagger-ui .servers select:focus { 47 | border-color: #40a9ff; 48 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 49 | outline: none; 50 | } 51 | 52 | /* 服务器选择器容器 */ 53 | .swagger-ui .servers { 54 | position: relative; 55 | } 56 | 57 | /* 调整操作项的样式 */ 58 | .swagger-ui .opblock { 59 | border-radius: 6px; 60 | border: 1px solid #e5e7eb; 61 | margin-bottom: 8px; 62 | box-shadow: none; 63 | width: 100%; 64 | margin-left: 0; 65 | margin-right: 0; 66 | } 67 | 68 | .swagger-ui .opblock.opblock-get { 69 | border-color: #61affe; 70 | background: rgba(97, 175, 254, 0.03); 71 | } 72 | 73 | .swagger-ui .opblock.opblock-post { 74 | border-color: #49cc90; 75 | background: rgba(73, 204, 144, 0.03); 76 | } 77 | 78 | .swagger-ui .opblock.opblock-put { 79 | border-color: #fca130; 80 | background: rgba(252, 161, 48, 0.03); 81 | } 82 | 83 | .swagger-ui .opblock.opblock-delete { 84 | border-color: #f93e3e; 85 | background: rgba(249, 62, 62, 0.03); 86 | } 87 | 88 | .swagger-ui .opblock.opblock-patch { 89 | border-color: #50e3c2; 90 | background: rgba(80, 227, 194, 0.03); 91 | } 92 | 93 | /* 调整展开的操作项样式 */ 94 | .swagger-ui .opblock.is-open { 95 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 96 | } 97 | 98 | /* 调整参数表格样式 */ 99 | .swagger-ui .parameters-container { 100 | background: transparent; 101 | } 102 | 103 | .swagger-ui .parameter__name { 104 | font-family: Monaco, Consolas, monospace; 105 | font-weight: 600; 106 | } 107 | 108 | /* 调整响应区域样式 */ 109 | .swagger-ui .responses-wrapper { 110 | background: transparent; 111 | } 112 | 113 | /* 调整Try it out按钮 */ 114 | .swagger-ui .btn.try-out__btn { 115 | background: #1890ff; 116 | color: white; 117 | border: none; 118 | border-radius: 4px; 119 | padding: 6px 16px; 120 | font-size: 14px; 121 | } 122 | 123 | .swagger-ui .btn.try-out__btn:hover { 124 | background: #40a9ff; 125 | } 126 | 127 | /* 调整Execute按钮 */ 128 | .swagger-ui .btn.execute { 129 | background: #52c41a; 130 | color: white; 131 | border: none; 132 | border-radius: 4px; 133 | padding: 8px 20px; 134 | font-size: 14px; 135 | font-weight: 500; 136 | } 137 | 138 | .swagger-ui .btn.execute:hover { 139 | background: #73d13d; 140 | } 141 | 142 | /* 调整Clear按钮 */ 143 | .swagger-ui .btn.btn-clear { 144 | background: #ff4d4f; 145 | color: white; 146 | border: none; 147 | border-radius: 4px; 148 | padding: 6px 16px; 149 | font-size: 14px; 150 | } 151 | 152 | .swagger-ui .btn.btn-clear:hover { 153 | background: #ff7875; 154 | } 155 | 156 | /* 调整模型区域 */ 157 | .swagger-ui .model-container { 158 | background: #f8f9fa; 159 | border: 1px solid #e9ecef; 160 | border-radius: 4px; 161 | } 162 | 163 | /* 调整代码高亮 */ 164 | .swagger-ui .highlight-code { 165 | background: #2d3748; 166 | border-radius: 4px; 167 | } 168 | 169 | /* 调整输入框样式 */ 170 | .swagger-ui input[type=text], 171 | .swagger-ui input[type=password], 172 | .swagger-ui input[type=search], 173 | .swagger-ui input[type=email], 174 | .swagger-ui input[type=url], 175 | .swagger-ui input[type=number] { 176 | border: 1px solid #d9d9d9; 177 | border-radius: 4px; 178 | padding: 6px 11px; 179 | font-size: 14px; 180 | line-height: 1.5; 181 | } 182 | 183 | .swagger-ui input[type=text]:focus, 184 | .swagger-ui input[type=password]:focus, 185 | .swagger-ui input[type=search]:focus, 186 | .swagger-ui input[type=email]:focus, 187 | .swagger-ui input[type=url]:focus, 188 | .swagger-ui input[type=number]:focus { 189 | border-color: #40a9ff; 190 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 191 | outline: none; 192 | } 193 | 194 | /* 调整文本域样式 */ 195 | .swagger-ui textarea { 196 | border: 1px solid #d9d9d9; 197 | border-radius: 4px; 198 | padding: 6px 11px; 199 | font-size: 14px; 200 | line-height: 1.5; 201 | } 202 | 203 | .swagger-ui textarea:focus { 204 | border-color: #40a9ff; 205 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 206 | outline: none; 207 | } 208 | 209 | /* 调整下拉选择样式 */ 210 | .swagger-ui select { 211 | border: 1px solid #d9d9d9; 212 | border-radius: 4px; 213 | padding: 6px 11px; 214 | font-size: 14px; 215 | line-height: 1.5; 216 | } 217 | 218 | .swagger-ui select:focus { 219 | border-color: #40a9ff; 220 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 221 | outline: none; 222 | } 223 | 224 | /* 隐藏授权部分(如果不需要) */ 225 | .swagger-ui .auth-wrapper { 226 | display: none; 227 | } 228 | 229 | /* 调整整体字体 */ 230 | .swagger-ui { 231 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 232 | } 233 | 234 | 235 | /* 调整标题样式 */ 236 | .swagger-ui .opblock-summary-description { 237 | font-size: 14px; 238 | color: #666; 239 | } 240 | 241 | /* 调整HTTP方法标签 */ 242 | .swagger-ui .opblock-summary-method { 243 | font-weight: bold; 244 | text-transform: uppercase; 245 | border-radius: 3px; 246 | padding: 6px 12px; 247 | font-size: 12px; 248 | min-width: 60px; 249 | text-align: center; 250 | } 251 | 252 | /* 调整路径显示 */ 253 | .swagger-ui .opblock-summary-path { 254 | font-family: Monaco, Consolas, monospace; 255 | font-size: 16px; 256 | font-weight: 500; 257 | } 258 | 259 | /* 移除不必要的边距 */ 260 | .swagger-ui .swagger-container { 261 | max-width: none !important; 262 | width: 100% !important; 263 | padding: 0; 264 | margin: 0; 265 | } 266 | 267 | /* 调整顶层wrapper */ 268 | .swagger-ui .wrapper { 269 | padding: 0; 270 | margin: 0; 271 | width: 100% !important; 272 | max-width: none !important; 273 | } 274 | 275 | /* 移除左侧空白 */ 276 | .swagger-ui .information-container { 277 | margin: 0; 278 | padding: 0; 279 | } 280 | 281 | /* 移除整体左边距 */ 282 | .swagger-ui { 283 | margin-left: 0 !important; 284 | padding-left: 0 !important; 285 | } 286 | 287 | /* 移除操作块的左边距 */ 288 | .swagger-ui .opblock-tag-section { 289 | margin-left: 0 !important; 290 | padding-left: 0 !important; 291 | margin-right: 0 !important; 292 | padding-right: 0 !important; 293 | width: 100% !important; 294 | } 295 | 296 | /* 确保接口标签区域占满宽度 */ 297 | .swagger-ui .opblock-tag { 298 | width: 100%; 299 | margin: 0; 300 | } 301 | 302 | /* 强制所有Swagger UI容器占满宽度 */ 303 | .swagger-ui-wrapper { 304 | width: 100% !important; 305 | } 306 | 307 | .swagger-ui { 308 | width: 100% !important; 309 | max-width: none !important; 310 | } 311 | 312 | .swagger-ui .info { 313 | width: 100% !important; 314 | } 315 | 316 | .swagger-ui .scheme-container { 317 | width: 100% !important; 318 | max-width: none !important; 319 | } 320 | 321 | /* 强制内容区域占满宽度 */ 322 | .swagger-ui .swagger-container .wrapper { 323 | width: 100% !important; 324 | max-width: none !important; 325 | } 326 | 327 | /* 强制操作列表容器占满宽度 */ 328 | .swagger-ui .swagger-container .wrapper .col-12 { 329 | width: 100% !important; 330 | max-width: none !important; 331 | flex: 0 0 100% !important; 332 | } 333 | 334 | /* Servers标题样式 */ 335 | .swagger-ui .servers-title { 336 | font-size: 14px !important; 337 | font-weight: 500 !important; 338 | margin-bottom: 8px !important; 339 | color: #262626 !important; 340 | text-align: left !important; 341 | } 342 | 343 | /* 接口列表标题样式 */ 344 | .swagger-ui .opblock-tag { 345 | font-size: 16px !important; 346 | font-weight: 500 !important; 347 | text-align: left !important; 348 | margin-left: 0 !important; 349 | } 350 | 351 | /* 去掉复制按钮的边框 */ 352 | .copy-server-btn { 353 | border: none !important; 354 | background: transparent !important; 355 | padding: 6px 8px !important; 356 | color: #666 !important; 357 | transition: all 0.2s !important; 358 | } 359 | 360 | .copy-server-btn:hover { 361 | background: #f5f5f5 !important; 362 | color: #1890ff !important; 363 | } 364 | 365 | /* 调整接口列表与上方分割线的距离 */ 366 | .swagger-ui .opblock-tag-section { 367 | margin-top: 20px !important; 368 | } 369 | 370 | /* 调整分割线样式,确保与接口边框分开 */ 371 | .swagger-ui .opblock-tag h3 { 372 | margin-bottom: 20px !important; 373 | padding-bottom: 12px !important; 374 | border-bottom: 1px solid #e8e8e8 !important; 375 | } 376 | 377 | /* 确保第一个接口容器与分割线有足够间距 */ 378 | .swagger-ui .opblock-tag-section .opblock:first-child { 379 | margin-top: 16px !important; 380 | } 381 | } 382 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/components/SwaggerUIWrapper.css: -------------------------------------------------------------------------------- ```css 1 | /* Swagger UI 自定义样式 */ 2 | .swagger-ui-wrapper { 3 | /* 隐藏顶部的信息栏,因为我们已经在上层显示了 */ 4 | .swagger-ui .info { 5 | display: none; 6 | } 7 | 8 | /* 完全隐藏服务器选择器的容器样式 */ 9 | .swagger-ui .scheme-container { 10 | padding: 0; 11 | background: transparent; 12 | border: none; 13 | margin-bottom: 16px; 14 | position: relative; 15 | box-shadow: none; 16 | } 17 | 18 | /* 隐藏服务器区域的所有边框和背景 */ 19 | .swagger-ui .scheme-container > div { 20 | background: transparent !important; 21 | border: none !important; 22 | box-shadow: none !important; 23 | padding: 0 !important; 24 | } 25 | 26 | /* 服务器URL样式优化 */ 27 | .swagger-ui .servers-title { 28 | font-weight: 600; 29 | margin-bottom: 8px; 30 | color: #262626; 31 | } 32 | 33 | .swagger-ui .servers select { 34 | font-family: Monaco, Consolas, monospace; 35 | background: white; 36 | border: 1px solid #d9d9d9; 37 | border-radius: 4px; 38 | padding: 8px 40px 8px 12px; 39 | font-size: 14px; 40 | color: #1890ff; 41 | cursor: pointer; 42 | min-width: 300px; 43 | position: relative; 44 | } 45 | 46 | .swagger-ui .servers select:focus { 47 | border-color: #40a9ff; 48 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 49 | outline: none; 50 | } 51 | 52 | /* 服务器选择器容器 */ 53 | .swagger-ui .servers { 54 | position: relative; 55 | } 56 | 57 | /* 调整操作项的样式 */ 58 | .swagger-ui .opblock { 59 | border-radius: 6px; 60 | border: 1px solid #e5e7eb; 61 | margin-bottom: 8px; 62 | box-shadow: none; 63 | width: 100%; 64 | margin-left: 0; 65 | margin-right: 0; 66 | } 67 | 68 | .swagger-ui .opblock.opblock-get { 69 | border-color: #61affe; 70 | background: rgba(97, 175, 254, 0.03); 71 | } 72 | 73 | .swagger-ui .opblock.opblock-post { 74 | border-color: #49cc90; 75 | background: rgba(73, 204, 144, 0.03); 76 | } 77 | 78 | .swagger-ui .opblock.opblock-put { 79 | border-color: #fca130; 80 | background: rgba(252, 161, 48, 0.03); 81 | } 82 | 83 | .swagger-ui .opblock.opblock-delete { 84 | border-color: #f93e3e; 85 | background: rgba(249, 62, 62, 0.03); 86 | } 87 | 88 | .swagger-ui .opblock.opblock-patch { 89 | border-color: #50e3c2; 90 | background: rgba(80, 227, 194, 0.03); 91 | } 92 | 93 | /* 调整展开的操作项样式 */ 94 | .swagger-ui .opblock.is-open { 95 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 96 | } 97 | 98 | /* 调整参数表格样式 */ 99 | .swagger-ui .parameters-container { 100 | background: transparent; 101 | } 102 | 103 | .swagger-ui .parameter__name { 104 | font-family: Monaco, Consolas, monospace; 105 | font-weight: 600; 106 | } 107 | 108 | /* 调整响应区域样式 */ 109 | .swagger-ui .responses-wrapper { 110 | background: transparent; 111 | } 112 | 113 | /* 调整Try it out按钮 */ 114 | .swagger-ui .btn.try-out__btn { 115 | background: #1890ff; 116 | color: white; 117 | border: none; 118 | border-radius: 4px; 119 | padding: 6px 16px; 120 | font-size: 14px; 121 | } 122 | 123 | .swagger-ui .btn.try-out__btn:hover { 124 | background: #40a9ff; 125 | } 126 | 127 | /* 调整Execute按钮 */ 128 | .swagger-ui .btn.execute { 129 | background: #52c41a; 130 | color: white; 131 | border: none; 132 | border-radius: 4px; 133 | padding: 8px 20px; 134 | font-size: 14px; 135 | font-weight: 500; 136 | } 137 | 138 | .swagger-ui .btn.execute:hover { 139 | background: #73d13d; 140 | } 141 | 142 | /* 调整Clear按钮 */ 143 | .swagger-ui .btn.btn-clear { 144 | background: #ff4d4f; 145 | color: white; 146 | border: none; 147 | border-radius: 4px; 148 | padding: 6px 16px; 149 | font-size: 14px; 150 | } 151 | 152 | .swagger-ui .btn.btn-clear:hover { 153 | background: #ff7875; 154 | } 155 | 156 | /* 调整模型区域 */ 157 | .swagger-ui .model-container { 158 | background: #f8f9fa; 159 | border: 1px solid #e9ecef; 160 | border-radius: 4px; 161 | } 162 | 163 | /* 调整代码高亮 */ 164 | .swagger-ui .highlight-code { 165 | background: #2d3748; 166 | border-radius: 4px; 167 | } 168 | 169 | /* 调整输入框样式 */ 170 | .swagger-ui input[type=text], 171 | .swagger-ui input[type=password], 172 | .swagger-ui input[type=search], 173 | .swagger-ui input[type=email], 174 | .swagger-ui input[type=url], 175 | .swagger-ui input[type=number] { 176 | border: 1px solid #d9d9d9; 177 | border-radius: 4px; 178 | padding: 6px 11px; 179 | font-size: 14px; 180 | line-height: 1.5; 181 | } 182 | 183 | .swagger-ui input[type=text]:focus, 184 | .swagger-ui input[type=password]:focus, 185 | .swagger-ui input[type=search]:focus, 186 | .swagger-ui input[type=email]:focus, 187 | .swagger-ui input[type=url]:focus, 188 | .swagger-ui input[type=number]:focus { 189 | border-color: #40a9ff; 190 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 191 | outline: none; 192 | } 193 | 194 | /* 调整文本域样式 */ 195 | .swagger-ui textarea { 196 | border: 1px solid #d9d9d9; 197 | border-radius: 4px; 198 | padding: 6px 11px; 199 | font-size: 14px; 200 | line-height: 1.5; 201 | } 202 | 203 | .swagger-ui textarea:focus { 204 | border-color: #40a9ff; 205 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 206 | outline: none; 207 | } 208 | 209 | /* 调整下拉选择样式 */ 210 | .swagger-ui select { 211 | border: 1px solid #d9d9d9; 212 | border-radius: 4px; 213 | padding: 6px 11px; 214 | font-size: 14px; 215 | line-height: 1.5; 216 | } 217 | 218 | .swagger-ui select:focus { 219 | border-color: #40a9ff; 220 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 221 | outline: none; 222 | } 223 | 224 | /* 隐藏授权部分(如果不需要) */ 225 | .swagger-ui .auth-wrapper { 226 | display: none; 227 | } 228 | 229 | /* 调整整体字体 */ 230 | .swagger-ui { 231 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 232 | } 233 | 234 | 235 | /* 调整标题样式 */ 236 | .swagger-ui .opblock-summary-description { 237 | font-size: 14px; 238 | color: #666; 239 | } 240 | 241 | /* 调整HTTP方法标签 */ 242 | .swagger-ui .opblock-summary-method { 243 | font-weight: bold; 244 | text-transform: uppercase; 245 | border-radius: 3px; 246 | padding: 6px 12px; 247 | font-size: 12px; 248 | min-width: 60px; 249 | text-align: center; 250 | } 251 | 252 | /* 调整路径显示 */ 253 | .swagger-ui .opblock-summary-path { 254 | font-family: Monaco, Consolas, monospace; 255 | font-size: 16px; 256 | font-weight: 500; 257 | } 258 | 259 | /* 移除不必要的边距 */ 260 | .swagger-ui .swagger-container { 261 | max-width: none !important; 262 | width: 100% !important; 263 | padding: 0; 264 | margin: 0; 265 | } 266 | 267 | /* 调整顶层wrapper */ 268 | .swagger-ui .wrapper { 269 | padding: 0; 270 | margin: 0; 271 | width: 100% !important; 272 | max-width: none !important; 273 | } 274 | 275 | /* 移除左侧空白 */ 276 | .swagger-ui .information-container { 277 | margin: 0; 278 | padding: 0; 279 | } 280 | 281 | /* 移除整体左边距 */ 282 | .swagger-ui { 283 | margin-left: 0 !important; 284 | padding-left: 0 !important; 285 | } 286 | 287 | /* 移除操作块的左边距 */ 288 | .swagger-ui .opblock-tag-section { 289 | margin-left: 0 !important; 290 | padding-left: 0 !important; 291 | margin-right: 0 !important; 292 | padding-right: 0 !important; 293 | width: 100% !important; 294 | } 295 | 296 | /* 确保接口标签区域占满宽度 */ 297 | .swagger-ui .opblock-tag { 298 | width: 100%; 299 | margin: 0; 300 | } 301 | 302 | /* 强制所有Swagger UI容器占满宽度 */ 303 | .swagger-ui-wrapper { 304 | width: 100% !important; 305 | } 306 | 307 | .swagger-ui { 308 | width: 100% !important; 309 | max-width: none !important; 310 | } 311 | 312 | .swagger-ui .info { 313 | width: 100% !important; 314 | } 315 | 316 | .swagger-ui .scheme-container { 317 | width: 100% !important; 318 | max-width: none !important; 319 | } 320 | 321 | /* 强制内容区域占满宽度 */ 322 | .swagger-ui .swagger-container .wrapper { 323 | width: 100% !important; 324 | max-width: none !important; 325 | } 326 | 327 | /* 强制操作列表容器占满宽度 */ 328 | .swagger-ui .swagger-container .wrapper .col-12 { 329 | width: 100% !important; 330 | max-width: none !important; 331 | flex: 0 0 100% !important; 332 | } 333 | 334 | /* Servers标题样式 */ 335 | .swagger-ui .servers-title { 336 | font-size: 14px !important; 337 | font-weight: 500 !important; 338 | margin-bottom: 8px !important; 339 | color: #262626 !important; 340 | text-align: left !important; 341 | } 342 | 343 | /* 接口列表标题样式 */ 344 | .swagger-ui .opblock-tag { 345 | font-size: 16px !important; 346 | font-weight: 500 !important; 347 | text-align: left !important; 348 | margin-left: 0 !important; 349 | } 350 | 351 | /* 去掉复制按钮的边框 */ 352 | .copy-server-btn { 353 | border: none !important; 354 | background: transparent !important; 355 | padding: 6px 8px !important; 356 | color: #666 !important; 357 | transition: all 0.2s !important; 358 | } 359 | 360 | .copy-server-btn:hover { 361 | background: #f5f5f5 !important; 362 | color: #1890ff !important; 363 | } 364 | 365 | /* 调整接口列表与上方分割线的距离 */ 366 | .swagger-ui .opblock-tag-section { 367 | margin-top: 20px !important; 368 | } 369 | 370 | /* 调整分割线样式,确保与接口边框分开 */ 371 | .swagger-ui .opblock-tag h3 { 372 | margin-bottom: 20px !important; 373 | padding-bottom: 12px !important; 374 | border-bottom: 1px solid #e8e8e8 !important; 375 | } 376 | 377 | /* 确保第一个接口容器与分割线有足够间距 */ 378 | .swagger-ui .opblock-tag-section .opblock:first-child { 379 | margin-top: 16px !important; 380 | } 381 | } 382 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/client/HigressClient.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.client; 21 | 22 | import cn.hutool.core.map.MapBuilder; 23 | import cn.hutool.json.JSONUtil; 24 | import com.alibaba.apiopenplatform.service.gateway.HigressOperator; 25 | import com.alibaba.apiopenplatform.service.gateway.factory.HTTPClientFactory; 26 | import com.alibaba.apiopenplatform.support.gateway.HigressConfig; 27 | import lombok.Data; 28 | import lombok.extern.slf4j.Slf4j; 29 | import org.springframework.core.ParameterizedTypeReference; 30 | import org.springframework.http.*; 31 | import org.springframework.web.client.HttpClientErrorException; 32 | import org.springframework.web.client.RestTemplate; 33 | 34 | import java.util.List; 35 | import java.util.Map; 36 | 37 | @Slf4j 38 | public class HigressClient extends GatewayClient { 39 | 40 | private static final String HIGRESS_COOKIE_NAME = "_hi_sess"; 41 | 42 | private final RestTemplate restTemplate; 43 | private final HigressConfig config; 44 | private String higressToken; 45 | private final ThreadLocal<Boolean> isRetrying = new ThreadLocal<>(); 46 | 47 | public HigressClient(HigressConfig higressConfig) { 48 | this.config = higressConfig; 49 | this.restTemplate = HTTPClientFactory.createRestTemplate(); 50 | } 51 | 52 | public <T, R> T execute(String path, 53 | HttpMethod method, 54 | Map<String, String> queryParams, 55 | R body, 56 | ParameterizedTypeReference<T> responseType) { 57 | return execute(path, method, null, queryParams, body, responseType); 58 | } 59 | 60 | public <T, R> T execute(String path, 61 | HttpMethod method, 62 | Map<String, String> queryParams, 63 | R body, 64 | Class<T> responseType) { 65 | return execute(path, method, queryParams, body, 66 | ParameterizedTypeReference.forType(responseType)); 67 | } 68 | 69 | public <T, R> T execute(String path, 70 | HttpMethod method, 71 | HttpHeaders headers, 72 | Map<String, String> queryParams, 73 | R body, 74 | ParameterizedTypeReference<T> responseType) { 75 | try { 76 | return doExecute(path, method, headers, queryParams, body, responseType); 77 | } finally { 78 | isRetrying.remove(); 79 | } 80 | } 81 | 82 | private <T, R> T doExecute(String path, 83 | HttpMethod method, 84 | HttpHeaders headers, 85 | Map<String, String> queryParams, 86 | R body, 87 | ParameterizedTypeReference<T> responseType) { 88 | try { 89 | ensureConsoleToken(); 90 | 91 | // 构建URL 92 | String url = buildUrlWithParams(path, queryParams); 93 | 94 | // Headers 95 | HttpHeaders mergedHeaders = new HttpHeaders(); 96 | if (headers != null) { 97 | mergedHeaders.putAll(headers); 98 | } 99 | mergedHeaders.add("Cookie", HIGRESS_COOKIE_NAME + "=" + higressToken); 100 | 101 | ResponseEntity<T> response = restTemplate.exchange( 102 | url, 103 | method, 104 | new HttpEntity<>(body, mergedHeaders), 105 | responseType 106 | ); 107 | 108 | log.info("Higress response: status={}, body={}", 109 | response.getStatusCode(), JSONUtil.toJsonStr(response.getBody())); 110 | 111 | return response.getBody(); 112 | } catch (HttpClientErrorException e) { 113 | // 401重新登录,且只重试一次 114 | if (e.getStatusCode() == HttpStatus.UNAUTHORIZED 115 | && !Boolean.TRUE.equals(isRetrying.get())) { 116 | log.warn("Token expired, trying to relogin"); 117 | higressToken = null; 118 | isRetrying.set(true); 119 | return doExecute(path, method, headers, queryParams, body, responseType); 120 | } 121 | log.error("HTTP error executing Higress request: status={}, body={}", 122 | e.getStatusCode(), e.getResponseBodyAsString()); 123 | throw e; 124 | } catch (Exception e) { 125 | log.error("Error executing Higress request: {}", e.getMessage()); 126 | throw new RuntimeException("Failed to execute Higress request", e); 127 | } 128 | } 129 | 130 | private String buildUrlWithParams(String path, Map<String, String> queryParams) { 131 | StringBuilder url = new StringBuilder(buildUrl(path)); 132 | 133 | if (queryParams != null && !queryParams.isEmpty()) { 134 | url.append('?'); 135 | queryParams.forEach((key, value) -> { 136 | if (url.charAt(url.length() - 1) != '?') { 137 | url.append('&'); 138 | } 139 | url.append(key).append('=').append(value); 140 | }); 141 | } 142 | 143 | return url.toString(); 144 | } 145 | 146 | private String buildUrl(String path) { 147 | String baseUrl = config.getAddress(); 148 | 149 | baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; 150 | path = path.startsWith("/") ? path : "/" + path; 151 | return baseUrl + path; 152 | } 153 | 154 | private void ensureConsoleToken() { 155 | if (higressToken == null) { 156 | login(); 157 | } 158 | } 159 | 160 | private void login() { 161 | Map<Object, Object> loginParam = MapBuilder.create() 162 | .put("username", config.getUsername()) 163 | .put("password", config.getPassword()) 164 | .build(); 165 | 166 | HttpHeaders headers = new HttpHeaders(); 167 | headers.setContentType(MediaType.APPLICATION_JSON); 168 | 169 | ResponseEntity<String> response = restTemplate.exchange( 170 | buildUrl("/session/login"), 171 | HttpMethod.POST, 172 | new HttpEntity<>(loginParam, headers), 173 | String.class 174 | ); 175 | 176 | List<String> cookies = response.getHeaders().get("Set-Cookie"); 177 | if (cookies == null || cookies.isEmpty()) { 178 | throw new RuntimeException("No cookies received from server"); 179 | } 180 | 181 | this.higressToken = cookies.stream() 182 | .filter(cookie -> cookie.startsWith(HIGRESS_COOKIE_NAME + "=")) 183 | .findFirst() 184 | .map(cookie -> { 185 | int endIndex = cookie.indexOf(';'); 186 | return endIndex == -1 187 | ? cookie.substring(HIGRESS_COOKIE_NAME.length() + 1) 188 | : cookie.substring(HIGRESS_COOKIE_NAME.length() + 1, endIndex); 189 | }) 190 | .orElseThrow(() -> new RuntimeException("Failed to get Higress session token")); 191 | } 192 | 193 | @Override 194 | public void close() { 195 | HTTPClientFactory.closeClient(restTemplate); 196 | } 197 | 198 | public static void main(String[] args) { 199 | HigressConfig higressConfig = new HigressConfig(); 200 | higressConfig.setAddress("http://demo.higress.io"); 201 | higressConfig.setUsername("admin"); 202 | higressConfig.setPassword("admin"); 203 | 204 | HigressClient higressClient = new HigressClient(higressConfig); 205 | // Object mcpServerInfo = higressClient.execute("/v1/mcpServer", HttpMethod.GET, null, null, new ParameterizedTypeReference<Object>() { 206 | // }); 207 | 208 | HigressOperator.HigressPageResponse<HigressOperator.HigressMCPConfig> response = higressClient.execute("/v1/mcpServer", HttpMethod.GET, null, null, new ParameterizedTypeReference<HigressOperator.HigressPageResponse<HigressOperator.HigressMCPConfig>>() { 209 | }); 210 | System.out.println(JSONUtil.toJsonStr(response)); 211 | } 212 | 213 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/portal/PortalOverview.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import {Card, Row, Col, Statistic, Button, message} from 'antd' 2 | import { 3 | UserOutlined, 4 | ApiOutlined, 5 | LinkOutlined, 6 | CheckCircleFilled, 7 | MinusCircleFilled, 8 | EditOutlined, 9 | CopyOutlined 10 | } from '@ant-design/icons' 11 | import {Portal} from '@/types' 12 | import {useState, useEffect} from 'react' 13 | import {portalApi, apiProductApi} from '@/lib/api' 14 | import {copyToClipboard} from '@/lib/utils' 15 | import {useNavigate} from 'react-router-dom' 16 | 17 | interface PortalOverviewProps { 18 | portal: Portal 19 | onEdit?: () => void 20 | } 21 | 22 | export function PortalOverview({portal, onEdit}: PortalOverviewProps) { 23 | const navigate = useNavigate() 24 | const [apiCount, setApiCount] = useState(0) 25 | const [developerCount, setDeveloperCount] = useState(0) 26 | 27 | useEffect(() => { 28 | if (!portal.portalId) return; 29 | 30 | portalApi.getDeveloperList(portal.portalId, { 31 | page: 1, 32 | size: 10 33 | }).then((res: any) => { 34 | setDeveloperCount(res.data.totalElements || 0) 35 | }) 36 | apiProductApi.getApiProducts({ 37 | portalId: portal.portalId, 38 | page: 1, 39 | size: 10 40 | }).then((res: any) => { 41 | setApiCount(res.data.totalElements || 0) 42 | }) 43 | 44 | }, [portal.portalId]) // 只依赖portalId,而不是整个portal对象 45 | 46 | return ( 47 | <div className="p-6 space-y-6"> 48 | <div> 49 | <h1 className="text-2xl font-bold mb-2">概览</h1> 50 | <p className="text-gray-600">Portal概览</p> 51 | </div> 52 | 53 | {/* 基本信息 */} 54 | <Card 55 | title="基本信息" 56 | extra={ 57 | onEdit && ( 58 | <Button 59 | type="primary" 60 | icon={<EditOutlined />} 61 | onClick={onEdit} 62 | > 63 | 编辑 64 | </Button> 65 | ) 66 | } 67 | > 68 | <div> 69 | <div className="grid grid-cols-6 gap-8 items-center pt-0 pb-2"> 70 | <span className="text-xs text-gray-600">Portal名称:</span> 71 | <span className="col-span-2 text-xs text-gray-900">{portal.name}</span> 72 | <span className="text-xs text-gray-600">Portal ID:</span> 73 | <div className="col-span-2 flex items-center gap-2"> 74 | <span className="text-xs text-gray-700">{portal.portalId}</span> 75 | <Button 76 | type="text" 77 | size="small" 78 | icon={<CopyOutlined />} 79 | onClick={async () => { 80 | try { 81 | await copyToClipboard(portal.portalId); 82 | message.success('Portal ID已复制'); 83 | } catch { 84 | message.error('复制失败,请手动复制'); 85 | } 86 | }} 87 | className="h-auto p-1 min-w-0" 88 | /> 89 | </div> 90 | </div> 91 | 92 | <div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2"> 93 | <span className="text-xs text-gray-600">域名:</span> 94 | <div className="col-span-2 flex items-center gap-2"> 95 | <LinkOutlined className="text-blue-500" /> 96 | <a 97 | href={`http://${portal.portalDomainConfig?.[0]?.domain}`} 98 | target="_blank" 99 | rel="noopener noreferrer" 100 | className="text-xs text-blue-600 hover:underline" 101 | > 102 | {portal.portalDomainConfig?.[0]?.domain} 103 | </a> 104 | </div> 105 | <span className="text-xs text-gray-600">账号密码登录:</span> 106 | <div className="col-span-2 flex items-center"> 107 | {portal.portalSettingConfig?.builtinAuthEnabled ? ( 108 | <CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} /> 109 | ) : ( 110 | <MinusCircleFilled className="text-gray-400 mr-2" style={{fontSize: '10px'}} /> 111 | )} 112 | <span className="text-xs text-gray-900"> 113 | {portal.portalSettingConfig?.builtinAuthEnabled ? '已启用' : '已停用'} 114 | </span> 115 | </div> 116 | </div> 117 | 118 | <div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2"> 119 | <span className="text-xs text-gray-600">开发者自动审批:</span> 120 | <div className="col-span-2 flex items-center"> 121 | {portal.portalSettingConfig?.autoApproveDevelopers ? ( 122 | <CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} /> 123 | ) : ( 124 | <MinusCircleFilled className="text-gray-400 mr-2" style={{fontSize: '10px'}} /> 125 | )} 126 | <span className="text-xs text-gray-900"> 127 | {portal.portalSettingConfig?.autoApproveDevelopers ? '已启用' : '已停用'} 128 | </span> 129 | </div> 130 | <span className="text-xs text-gray-600">订阅自动审批:</span> 131 | <div className="col-span-2 flex items-center"> 132 | {portal.portalSettingConfig?.autoApproveSubscriptions ? ( 133 | <CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} /> 134 | ) : ( 135 | <MinusCircleFilled className="text-gray-400 mr-2" style={{fontSize: '10px'}} /> 136 | )} 137 | <span className="text-xs text-gray-900"> 138 | {portal.portalSettingConfig?.autoApproveSubscriptions ? '已启用' : '已停用'} 139 | </span> 140 | </div> 141 | </div> 142 | 143 | <div className="grid grid-cols-6 gap-8 items-start pt-2 pb-2"> 144 | <span className="text-xs text-gray-600">描述:</span> 145 | <span className="col-span-5 text-xs text-gray-900 leading-relaxed"> 146 | {portal.description || '-'} 147 | </span> 148 | </div> 149 | </div> 150 | </Card> 151 | 152 | {/* 统计数据 */} 153 | <Row gutter={[16, 16]}> 154 | <Col xs={24} sm={12} lg={12}> 155 | <Card 156 | className="cursor-pointer hover:shadow-md transition-shadow" 157 | onClick={() => { 158 | navigate(`/portals/detail?id=${portal.portalId}&tab=developers`) 159 | }} 160 | > 161 | <Statistic 162 | title="注册开发者" 163 | value={developerCount} 164 | prefix={<UserOutlined className="text-blue-500" />} 165 | valueStyle={{ color: '#1677ff', fontSize: '24px' }} 166 | /> 167 | </Card> 168 | </Col> 169 | <Col xs={24} sm={12} lg={12}> 170 | <Card 171 | className="cursor-pointer hover:shadow-md transition-shadow" 172 | onClick={() => { 173 | navigate(`/portals/detail?id=${portal.portalId}&tab=published-apis`) 174 | }} 175 | > 176 | <Statistic 177 | title="已发布的API" 178 | value={apiCount} 179 | prefix={<ApiOutlined className="text-blue-500" />} 180 | valueStyle={{ color: '#1677ff', fontSize: '24px' }} 181 | /> 182 | </Card> 183 | </Col> 184 | </Row> 185 | </div> 186 | ) 187 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/components/consumer/SubscriptionManager.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState } from "react"; 2 | import { 3 | Card, 4 | Button, 5 | message, 6 | Input, 7 | Modal, 8 | Table, 9 | Badge, 10 | Popconfirm, 11 | Select, 12 | } from "antd"; 13 | import { 14 | PlusOutlined, 15 | } from "@ant-design/icons"; 16 | import api from "../../lib/api"; 17 | import type { Subscription } from "../../types/consumer"; 18 | import type { ApiResponse, Product } from "../../types"; 19 | import { getSubscriptionStatusText, getSubscriptionStatusColor } from "../../lib/statusUtils"; 20 | import { formatDateTime } from "../../lib/utils"; 21 | 22 | interface SubscriptionManagerProps { 23 | consumerId: string; 24 | subscriptions: Subscription[]; 25 | onSubscriptionsChange: (searchParams?: { productName: string; status: string }) => void; 26 | loading?: boolean; 27 | } 28 | 29 | export function SubscriptionManager({ consumerId, subscriptions, onSubscriptionsChange, loading = false }: SubscriptionManagerProps) { 30 | const [productModalVisible, setProductModalVisible] = useState(false); 31 | const [filteredProducts, setFilteredProducts] = useState<Product[]>([]); 32 | const [productLoading, setProductLoading] = useState(false); 33 | const [subscribeLoading, setSubscribeLoading] = useState(false); 34 | const [selectedProduct, setSelectedProduct] = useState<string>(''); 35 | const [subscriptionSearch, setSubscriptionSearch] = useState({ productName: '', status: '' as 'PENDING' | 'APPROVED' | '' }); 36 | 37 | // 过滤产品:移除已订阅的产品 38 | const filterProducts = (allProducts: Product[]) => { 39 | // 获取已订阅的产品ID列表 40 | const subscribedProductIds = subscriptions.map(sub => sub.productId); 41 | 42 | // 过滤掉已订阅的产品 43 | return allProducts.filter(product => 44 | !subscribedProductIds.includes(product.productId) 45 | ); 46 | }; 47 | 48 | const openProductModal = async () => { 49 | setProductModalVisible(true); 50 | setProductLoading(true); 51 | try { 52 | const response: ApiResponse<{ content: Product[] }> = await api.get("/products?page=0&size=100"); 53 | if (response?.code === "SUCCESS" && response?.data) { 54 | const allProducts = response.data.content || []; 55 | // 初始化时过滤掉已订阅的产品 56 | const filtered = filterProducts(allProducts); 57 | setFilteredProducts(filtered); 58 | } 59 | } catch (error) { 60 | console.error('获取产品列表失败:', error); 61 | // message.error('获取产品列表失败'); 62 | } finally { 63 | setProductLoading(false); 64 | } 65 | }; 66 | 67 | 68 | const handleSubscribeProducts = async () => { 69 | if (!selectedProduct) { 70 | message.warning('请选择要订阅的产品'); 71 | return; 72 | } 73 | 74 | setSubscribeLoading(true); 75 | try { 76 | await api.post(`/consumers/${consumerId}/subscriptions`, { productId: selectedProduct }); 77 | message.success('订阅成功'); 78 | setProductModalVisible(false); 79 | setSelectedProduct(''); 80 | onSubscriptionsChange(); 81 | } catch (error) { 82 | console.error('订阅失败:', error); 83 | // message.error('订阅失败'); 84 | } finally { 85 | setSubscribeLoading(false); 86 | } 87 | }; 88 | 89 | const handleUnsubscribe = async (productId: string) => { 90 | try { 91 | await api.delete(`/consumers/${consumerId}/subscriptions/${productId}`); 92 | message.success('取消订阅成功'); 93 | onSubscriptionsChange(); 94 | } catch (error) { 95 | console.error('取消订阅失败:', error); 96 | // message.error('取消订阅失败'); 97 | } 98 | }; 99 | 100 | 101 | 102 | const subscriptionColumns = [ 103 | { 104 | title: '产品名称', 105 | dataIndex: 'productName', 106 | key: 'productName', 107 | render: (productName: Product['productName']) => productName || '-', 108 | }, 109 | { 110 | title: '产品类型', 111 | dataIndex: 'productType', 112 | key: 'productType', 113 | render: (productType: Product['productType']) => { 114 | const typeMap = { 115 | 'REST_API': 'REST API', 116 | 'HTTP_API': 'HTTP API', 117 | 'MCP_SERVER': 'MCP Server' 118 | }; 119 | return typeMap[productType as keyof typeof typeMap] || productType || '-'; 120 | } 121 | }, 122 | { 123 | title: '订阅状态', 124 | dataIndex: 'status', 125 | key: 'status', 126 | render: (status: string) => ( 127 | <Badge status={getSubscriptionStatusColor(status) as 'success' | 'processing' | 'error' | 'default' | 'warning'} text={getSubscriptionStatusText(status)} /> 128 | ), 129 | }, 130 | { 131 | title: '订阅时间', 132 | dataIndex: 'createAt', 133 | key: 'createAt', 134 | render: (date: string) => date ? formatDateTime(date) : '-', 135 | }, 136 | { 137 | title: '操作', 138 | key: 'action', 139 | render: (record: Subscription) => ( 140 | <Popconfirm 141 | title="确定要取消订阅吗?" 142 | onConfirm={() => handleUnsubscribe(record.productId)} 143 | > 144 | <Button type="link" danger size="small"> 145 | 取消订阅 146 | </Button> 147 | </Popconfirm> 148 | ), 149 | }, 150 | ]; 151 | 152 | // 确保 subscriptions 始终是数组 153 | const safeSubscriptions = Array.isArray(subscriptions) ? subscriptions : []; 154 | 155 | return ( 156 | <> 157 | <Card> 158 | <div className="mb-4 flex justify-between items-center"> 159 | <div className="flex space-x-4"> 160 | <Button 161 | type="primary" 162 | icon={<PlusOutlined />} 163 | onClick={openProductModal} 164 | > 165 | 订阅 166 | </Button> 167 | <Input.Search 168 | placeholder="请输入API名称进行搜索" 169 | style={{ width: 300 }} 170 | onSearch={(value) => { 171 | const newSearch = { ...subscriptionSearch, productName: value }; 172 | setSubscriptionSearch(newSearch); 173 | onSubscriptionsChange(newSearch); 174 | }} 175 | /> 176 | <Select 177 | placeholder="订阅状态" 178 | style={{ width: 120 }} 179 | allowClear 180 | value={subscriptionSearch.status || undefined} 181 | onChange={(value) => { 182 | const newSearch = { ...subscriptionSearch, status: value as 'PENDING' | 'APPROVED' | '' }; 183 | setSubscriptionSearch(newSearch); 184 | onSubscriptionsChange(newSearch); 185 | }} 186 | > 187 | <Select.Option value="PENDING">待审批</Select.Option> 188 | <Select.Option value="APPROVED">已通过</Select.Option> 189 | </Select> 190 | </div> 191 | </div> 192 | <Table 193 | columns={subscriptionColumns} 194 | dataSource={safeSubscriptions} 195 | rowKey={(record) => record.productId} 196 | pagination={false} 197 | size="small" 198 | loading={loading} 199 | locale={{ emptyText: '暂无订阅记录,请点击上方按钮进行订阅' }} 200 | /> 201 | </Card> 202 | 203 | {/* 产品选择弹窗 */} 204 | <Modal 205 | title="订阅产品" 206 | open={productModalVisible} 207 | onCancel={() => { 208 | if (!subscribeLoading) { 209 | setProductModalVisible(false); 210 | setSelectedProduct(''); 211 | } 212 | }} 213 | footer={ 214 | <div className="flex justify-end space-x-2"> 215 | <Button 216 | onClick={() => { 217 | if (!subscribeLoading) { 218 | setProductModalVisible(false); 219 | setSelectedProduct(''); 220 | } 221 | }} 222 | disabled={subscribeLoading} 223 | > 224 | 取消 225 | </Button> 226 | <Button 227 | type="primary" 228 | onClick={handleSubscribeProducts} 229 | disabled={!selectedProduct} 230 | loading={subscribeLoading} 231 | > 232 | 确定订阅 233 | </Button> 234 | </div> 235 | } 236 | width={500} 237 | styles={{ 238 | content: { 239 | borderRadius: '8px', 240 | padding: 0 241 | }, 242 | header: { 243 | borderRadius: '8px 8px 0 0', 244 | marginBottom: 0, 245 | paddingBottom: '8px' 246 | }, 247 | body: { 248 | padding: '24px' 249 | } 250 | }} 251 | > 252 | <div> 253 | <div className="text-sm text-gray-700 mb-3 font-medium">选择要订阅的产品:</div> 254 | <Select 255 | placeholder="请输入产品名称进行搜索或直接选择" 256 | style={{ width: '100%' }} 257 | value={selectedProduct} 258 | onChange={setSelectedProduct} 259 | loading={productLoading} 260 | showSearch={true} 261 | filterOption={(input, option) => { 262 | const product = filteredProducts.find(p => p.productId === option?.value); 263 | if (!product) return false; 264 | 265 | const searchText = input.toLowerCase(); 266 | return ( 267 | product.name?.toLowerCase().includes(searchText) || 268 | product.description?.toLowerCase().includes(searchText) 269 | ); 270 | }} 271 | notFoundContent={productLoading ? '加载中...' : '暂无可订阅的产品'} 272 | > 273 | {filteredProducts.map(product => ( 274 | <Select.Option key={product.productId} value={product.productId}> 275 | {product.name} 276 | </Select.Option> 277 | ))} 278 | </Select> 279 | </div> 280 | </Modal> 281 | </> 282 | ); 283 | } ```