ABP9.3.0+React 19.2.0基础创建

244 阅读11分钟

前/后端技术栈

服务端:

ABP Framework v9.3.0
	
ORM:
	EFCore   v9.3.0
        -SqlServer V2022
      

环境:
	.NET SDK 9.0
	
工具:
	.ABP CLI 9.3.0

客户端:

Web:
	React + Vite + TypeScript
	-UI:
		Antd
	

环境:
	Node v20.19.6
	pnpm v10.23.0
	Android SDK
        Vite 和 React 对环境要求较宽松:支持 Node.js 较新版本(>=14/16/18),通常无需严格固定版本即可运行。
工具:	
	vite 	v7.2.4
	nvm 	v1.2.2
	vite-cli:通过npm命令,不需要全局安装

1.创建

创建相应的框架项目文件

后端ABP

//ABP官网
https://abp.io/docs/latest/get-started/layered-web-application  

🌈使用ABP_CLI创建

//手脚架说明
https://abp.io/docs/latest/cli 
//手脚架快速命令构建页面
https://abp.io/get-started
确认当前 CLI 安装的版本:abp --version

//模板选择指南
https://abp.io/docs/9.3/solution-templates/guide


//指定文件夹,CMD
输入命令:   
abp new AppCoreServer -t app  -u none -d ef -v 9.3.0
//生成完后即可看到/AppCoreServer/aspnet-core文件夹

项目结构

aspnet-core/                	 		# 解决方案。
   ├── src/                   		 	# 后端主服务代码目录。
   │   ├── Application/        		 	# 应用服务层,处理业务逻辑。
   │   ├── Application.Contracts/       # 应用服务层接口定义层。
   │   ├── DbMigrator/             		# 迁移层,用于配置,定义迁移数据库。
   │   ├── Domain/             			# 领域层,包含领域模型和业务规则。
   │   ├── Domain.Shared/             	# 存放通用类和定义。
   │   ├── EntityFrameworkCore/ 		# 数据访问层,使用 EF Core 实现数据库操作。
   │   ├── HttpApi/            			# HTTP API 层,定义 API 接口,暴露 REST API。
   │   ├── HttpApi.Client/     			# 生成 C# SDK,用于其它程序直接消费API-(可以让另一个c#项目引用该层
   │   │									,然后调用Application一样调用,而不是写httlclient调用,适用于微服务,分布式)
   │   └── HttpApi.Host/       			# 项目根模块,配置和运行后端服务,Program就在这里。
   │
   └── test/                   			# 测试单元。
   │   ├── Application.Tests/  			# 应用服务层的测试。
   │   ├── Domain.Tests/       			# 领域层的测试。
   │   └── IntegrationTests/   			# 集成测试。
   │
   └──AppCoreServer.sln					#解决方案文件,包含项目路径、依赖顺序,.net10开始变成slnx
   |
   └──.gitignore						#定义 Git 不需要追踪的文件和目录。

附录

🧱 ABP 项目类型总览对比表
类型名称特点适用场景是否分层是否前后端分离是否支持模块化是否为微服务
1️⃣单层 Web 应用 (Single Layer Web App)所有代码在一个项目中快速开发、小项目、原型❌(默认集成 UI)
2️⃣分层 Web 应用 (Layered Web App)领域、应用、接口、EFCore 层分离中大型项目,清晰架构❌(默认集成 UI)
3️⃣模块项目 (Module Project)可复用、可移植、可发布为 NuGet 模块公共服务模块、组件库✅(专为模块开发)
4️⃣微服务解决方案 (Microservice Solution)多服务、多数据库、前后端分离大型系统、分布式部署✅(UI 独立)

前端React

前端React+Vite手脚架

//React官网
https://zh-hans.react.dev/learn/creating-a-react-app

//Vite官网
https://vitejs.cn/vite5-cn/

🌈使用Vite创建

//使用Vite手脚架创建React+TypeScript项目


cmd:

D:\AppWmcsServer>npm create vite@latest
> npx
> create-vite

|
o  Project name:						//项目名
|  react-web
|
o  Select a framework:   				//框架
|  React
|
o  Select a variant:   					//选择一种版本
|  TypeScript + SWC
|
o  Use rolldown-vite (Experimental)?:  	//是否使用实验性功能,否
|  No
|
o  Install with npm and start now?   	//是否安装依赖,否
|  No
|
o  Scaffolding project in D:\AppWmcsServer\react-web...
|
—  Done. Now run:

  cd react-web
  npm install
  npm run dev
D:\0App\ResourceCenter\2App\AppWmcsServer>


//TypeScript + SWC:
//使用 TypeScript 语言编写项目代码
//使用 SWC 作为构建时的编译器(替代 Babel 或 TSC)将.ts、.tsx 文件会被 SWC 编译为 JavaScript,速度比用 Babel 快很多

项目结构

react-web/
├── node_modules/       # 存放项目的依赖包,由 npm 或 yarn 自动生成,不需要手动修改。
├── public/             # 存放公共资源文件,直接复制到最终构建的输出中。
│   └── favicon.ico     # 项目的图标文件,通常显示在浏览器标签页中。
├── index.html      	# 项目的入口 HTML 文件。Vite 以此文件为基础生成最终的页面。
├── src/                # 源代码目录,存放主要的开发文件。
│   ├── assets/         # 存放静态资源文件,如图片、字体等。
│   ├── components/     # 存放可复用的 React 组件,每个组件可以独立开发和测试。
│   ├── pages/          # 存放页面级组件,每个页面代表一个路由或完整功能模块。
│   ├── App.tsx         # 主应用组件,通常包含路由和全局状态管理的内容。
│   ├── main.tsx        # 项目的入口文件,用于渲染根组件并挂载到 DOM 上。
│   └── vite-env.d.ts   # 为 Vite 特定的 TypeScript 类型声明文件,方便开发时的类型检查。
├── tsconfig.json       # 这是 TypeScript 项目的根配置文件,定义了全局的 TypeScript 编译选项
├── tsconfig.app.json   #这个文件是特定于应用的 TypeScript 配置文件    
├── tsconfig.node.json  #这个文件专门为 Node.js 环境配置 TypeScript。     
├── vite.config.ts      # Vite 的配置文件,用于自定义开发服务器、插件和构建流程。
├── package.json        # 项目的元数据文件,包含项目依赖、脚本和基本信息。
├── .eslintrc.cjs       # ESLint 的配置文件,用于规范代码风格和查找潜在问题。
├── .gitignore          # 定义 Git 不需要追踪的文件和目录。
└── README.md           # 自述文件项目的说明文档,包含项目简介、使用说明和贡献指南。

2.项目初始化

将项目模板结构不需要的文件修改删除调整

2.1 ABP

初始化Serilog日志

public static async Task<int> Main(string[] args)
    {
        Log.Logger = new LoggerConfiguration()
#if DEBUG
            .MinimumLevel.Debug()
#else
            .MinimumLevel.Information()
#endif
            .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
            .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
            .Enrich.FromLogContext()
            .WriteTo.Async(c => c.File(
                "Logs/log-.log",
                rollingInterval: RollingInterval.Day,
                rollOnFileSizeLimit: true,
                fileSizeLimitBytes: 1L * 1024 * 1024 * 1024 / 2, // 500MB
                retainedFileCountLimit: 10,
                shared: true
            ))
#if DEBUG  //debug时日志输出到控制台
            .WriteTo.Async(c => c.Console())
#endif    //日志输出文件中
            .CreateLogger();       

        try
        {
           //#省略...
        }
    }

解析

Serilog 是一个高性能、简单的日志库,用于记录应用程序的日志。它支持丰富的输出方式(控制台、文件、数据库等),并且允许动态配置日志级别。

.MinimumLevel.Debug() 和 .MinimumLevel.Information():这两行代码设置日志的最小级别。在 Debug 模式下,日志级别设置为 Debug,这意味着所有级别的日志都会被记录(包括 Debug、Information、Warning 等)。在 Release 模式下,日志级别设置为 Information,表示只记录 Information 级别及更高严重级别的日志。

.MinimumLevel.Override("Microsoft", LogEventLevel.Information):这行代码覆盖了 Microsoft 命名空间的日志级别,设置为 Information。这样就不会记录 Microsoft 相关的调试信息,减少日志冗余。

.WriteTo.Async(...):这里的 WriteTo 配置定义了日志的输出方式。Async 是指日志会异步写入,避免影响主线程的性能。它配置了:

File:日志会写入文件。日志文件会以 Logs/log-.log 命名,并且日志会按天分割。

rollingInterval: RollingInterval.Day:每一天都会生成一个新的日志文件。

rollOnFileSizeLimit: true:当文件大小超过限制时,日志会自动滚动(即生成新的文件)。

fileSizeLimitBytes: 1L * 1024 * 1024 * 1024 / 2:日志文件的最大大小为 500MB。

retainedFileCountLimit: 10:保留最近的 10 个日志文件,删除更早的文件。

shared: true:文件可被多个进程共享。

Console 输出(仅在 Debug 模式下):如果是 Debug 模式,还会输出日志到控制台。这样在调试时可以快速看到日志。

初始化配置文件

src/AppCoreServer.HttpApi.Host/appsettings.json 项目根配置文件

{
  "App": {
       //当前服务自身的根 URL,常用于服务注册或跳转(OpenIddict、身份认证等场景)
    "SelfUrl": "https://localhost:44239",
      
       //允许哪些前端域名进行跨域访问(用逗号或分号分隔)支持通配符如 https://*.xxx.com
    "CorsOrigins": "https://*.AppCoreServer.com",
      
      //【Openiddict提供,授权码模式备案名单】登录完成后允许跳转的 URL 白名单(防止重定向攻击)可设置为 https://yourfrontend.com
    "RedirectAllowedUrls": ""
  },
    
    //初始配置链接字符串,EFcore默认使用Default字符串
  "ConnectionStrings": {
    "Default": "Server=.;Database=AppCoreServer;uid=sa;pwd=sa;TrustServerCertificate=True"
  },
    //认证配置,见下文OpenIdDict认证授权
  "AuthServer": {
    "Authority": "https://localhost:44239",
    "RequireHttpsMetadata": false,
    "SwaggerClientId": "AppCoreServer_Swagger"
  },
    
  "StringEncryption": {
    "DefaultPassPhrase": "WVtuUXdarYDdaFZp"//编码密钥,用于一些编码服务
  }
}

src/AppCoreServer.DbMigrator/appsettings.json 项目迁移配置

{
    //迁移地址
  "ConnectionStrings": {
     "Default": "Server=.;Database=AppCoreServer;uid=sa;pwd=sa;TrustServerCertificate=True"
  },
    
  "Redis": {
    "Configuration": "127.0.0.1"
  },
    //OpenIddict认证模式配置
  "OpenIddict": {
    "Applications": {
      "AppCoreServer_Web": {
        "ClientId": "AppCoreServer_Web",
        "ClientSecret": "1q2w3e*",
        "RootUrl": "https://localhost:44320"
      },
      "AppCoreServer_App": {
        "ClientId": "AppCoreServer_App",
        "RootUrl": "http://localhost:4200"
      },
      "AppCoreServer_BlazorServerTiered": {
        "ClientId": "AppCoreServer_BlazorServerTiered",
        "ClientSecret": "1q2w3e*",
        "RootUrl": "https://localhost:44345"
      },
      "AppCoreServer_BlazorWebAppTiered": {
        "ClientId": "AppCoreServer_BlazorWebAppTiered",
        "ClientSecret": "1q2w3e*",
        "RootUrl": "https://localhost:44345"
      },
      "AppCoreServer_Swagger": {
        "ClientId": "AppCoreServer_Swagger",
        "RootUrl": "https://localhost:44355"
      }
    }
  }
}

launchSettings.json

//aspnet-core\src\AppCoreServer.HttpApi.Host\Properties\launchSettings.json
运行配置文件,用于开发时的端口监听配置,常用于编辑器配置启动编译,部署时忽略.

 "applicationUrl": "https://localhost:44239"//地址跟App:SelfUrl保持一致
启动方式使用配置底层宿主启动行为
IIS ExpressiisExpress.applicationUrlIIS Express 进程 (iisexpress.exe)通过 VS 或命令调用 iisexpress.exe 启动项目,不是用 dotnet run
Kestrel(dotnet run)applicationUrlKestrel 服务器 (dotnet)使用 dotnet run 启动程序,即调用 .dll 文件运行

2.2 React

🌈调整:

//新建src/view/App目录
	
view/App
├──App.tsx
└──App.css



//修改App.tsx:
const App: React.FC = () => {
    return(
        <StrictMode>
      
        </StrictMode>
    )
}


//修改main.tsx:
createRoot(document.getElementById('root')!).render(
    <App/>
)


//如上,main.tsx作为根目录入口文件,App.tsx作为全局配置来整理项目

StrictMode:

<StrictMode>React 提供的一种工具,用来检测应用中的潜在问题它不会渲染任何可见的 UI,只是对其子组件执行额外的检查和警告,帮助开发者编写更健壮的代码

主要功能
检测废弃的生命周期方法:例如 componentWillMountcomponentWillReceiveProps
识别不安全的操作:比如 findDOMNode 的使用
检查意外的副作用:React 会在开发环境下对组件的 render 和 useEffect 等进行两次调用,以帮助识别潜在的副作用
强制更严格的模式:提醒开发者采用 React 推荐的最佳实践

3.基础构建

3.1 后端跨域

🌈操作

   "App": {
    "SelfUrl": "https://localhost:55555",
    "CorsOrigins": "https://*.ServerApp.com"
        
 	//添加了允许全部
	"AllowAnyOrigin": "true"
  },
 
 context.Services.AddCors(options =>
        {
            options.AddDefaultPolicy(builder =>
            {
                builder
                    .WithAbpExposedHeaders()
                    .SetIsOriginAllowedToAllowWildcardSubdomains()
                    .AllowAnyHeader()
                    .AllowAnyMethod().AllowCredentials().WithExposedHeaders("x-elsa-workflow-instance-id");

                if (bool.Parse(configuration["App:AllowAnyOrigin"] ?? "false"))
                {
                    //允许全部
                    builder.SetIsOriginAllowed(_ => true);
                }
                else
                {
                    //否则按照CorsOrigins允许的来
                    builder
                        .WithOrigins(
                            (configuration["App:CorsOrigins"] ?? "")
                            .Split(",", StringSplitOptions.RemoveEmptyEntries)
                            .Select(o => o.RemovePostFix("/"))
                            .ToArray()
                        );
                }
            });
        });

3.2 OpenIddict授权认证

配置客户端

//aspnet-core\src\AppCoreServer.DbMigrator\appsettings.json 迁移文件配置
{
  "ConnectionStrings": {
    "Default": "Server=.;Database=AppCoreServer;uid=sa;pwd=sa;TrustServerCertificate=True"
  },
  "OpenIddict": {
    "Applications": {
      "AppCoreServer_Swagger": {
        "ClientId": "AppCoreServer_Swagger",
        "RootUrl": "https://localhost:55319"
      },
        //新增APP内部客户端,使用密码模式
        "AppCoreServer_App": {
        "ClientId": "AppCoreServer_App",
        "ClientSecret": "1q2w3E*"
      },
        //新增第三方访问客户端,使用授权码模式
      "AppCoreServer_ExternalSystems": {
        "ClientId": "AppCoreServer_ExternalSystems",
        "ClientSecret": "ExternalSystems_API"
      }
      
    }
  }
}

创建客户端

namespace AppCoreServer.OpenIddict;

/* Creates initial data that is needed to property run the application
 * and make client-to-server communication possible.
 */
public class OpenIddictDataSeedContributor : IDataSeedContributor, ITransientDependency
{
   //...省略

    [UnitOfWork]
    public virtual async Task SeedAsync(DataSeedContext context)
    {
        await CreateScopesAsync();
        await CreateApplicationsAsync();
    }

    private async Task CreateScopesAsync()
    {
        //框架默认创建
        if (await _openIddictScopeRepository.FindByNameAsync("AppCoreServer") == null)
        {
            await _scopeManager.CreateAsync(new OpenIddictScopeDescriptor {
                Name = "AppCoreServer", DisplayName = "AppCoreServer API", Resources = { "AppCoreServer" }
            });
        }
        
        //创建第三方访问客户端
        if (await _openIddictScopeRepository.FindByNameAsync("ExternalSystems") == null)
        {
            await _scopeManager.CreateAsync(new OpenIddictScopeDescriptor {
                Name = "ExternalSystems", DisplayName = "ExternalSystems API", Resources = { "ExternalSystems" }
            });
        }
    }

    private async Task CreateApplicationsAsync()
    {
        
     // ✅ 通用 Scope(所有客户端都能用的)
    var commonScopes = new List<string>
    {
        OpenIddictConstants.Permissions.Scopes.Address,
        OpenIddictConstants.Permissions. Scopes.Email,
        OpenIddictConstants.Permissions. Scopes.Phone,
        OpenIddictConstants.Permissions.Scopes.Profile,
        OpenIddictConstants. Permissions.Scopes.Roles,
        "AppCoreServer",  // 主 API 的 Scope
        "ExternalSystems"
    };

    var configurationSection = _configuration.GetSection("OpenIddict:Applications");

    // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    // 1️⃣ Swagger 客户端(公共客户端 + 授权码模式)
    // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    var swaggerClientId = configurationSection["AppCoreServer_Swagger: ClientId"];
    if (!swaggerClientId.IsNullOrWhiteSpace())
    {
        var swaggerRootUrl = configurationSection["AppCoreServer_Swagger:RootUrl"]?.TrimEnd('/');

        await CreateApplicationAsync(
            name: swaggerClientId!,
            type: OpenIddictConstants.ClientTypes.Public,  // ✅ 公共客户端
            consentType: OpenIddictConstants.ConsentTypes.Implicit,  // ✅ 自动同意
            displayName: "Swagger Application",
            secret: null,  // ✅ 公共客户端不需要 Secret
            grantTypes: new List<string>
            {
                OpenIddictConstants.GrantTypes.AuthorizationCode  // ✅ 授权码模式
            },
            scopes: commonScopes,
            redirectUri: $"{swaggerRootUrl}/swagger/oauth2-redirect.html",
            clientUri: swaggerRootUrl
        );
    }

    // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    // 2️⃣ App 客户端(内部网页/移动端 + 密码模式)
    // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    var appClientId = configurationSection["AppCoreServer_App:ClientId"];
    if (!appClientId. IsNullOrWhiteSpace())
    {
        var appClientSecret = configurationSection["AppCoreServer_App:ClientSecret"];  // ✅ 从配置读取 Secret

        await CreateApplicationAsync(
            name: appClientId!,
            type: OpenIddictConstants.ClientTypes. Public,
            consentType:  OpenIddictConstants.ConsentTypes.Implicit,  // ✅ 自动同意(内部系统)
            displayName: "App Application",
            secret: null,
            grantTypes: new List<string>
            {
                OpenIddictConstants.GrantTypes.Password,  // ✅ 密码模式
                OpenIddictConstants.GrantTypes.RefreshToken  // ✅ 支持刷新令牌
            },
            scopes: commonScopes,
            redirectUri: null, 
            clientUri: null
        );
    }

    // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    // 3️⃣ 第三方系统客户端(机密客户端 + 客户端凭证模式)
    // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    var externalSystemsClientId = configurationSection["AppCoreServer_ExternalSystems:ClientId"];
    if (!externalSystemsClientId.IsNullOrWhiteSpace())
    {
        var externalSystemsSecret = configurationSection["AppCoreServer_ExternalSystems:ClientSecret"];  // ✅ 读取 Secret

        // ✅ 第三方系统专用 Scope
        var externalScopes = new List<string>
        {
            "ExternalSystems"  // ✅ 只能访问外部系统 API
        };
        await CreateApplicationAsync(
            name: externalSystemsClientId!,
            type: OpenIddictConstants.ClientTypes.Confidential,  // ✅ 机密客户端
            consentType: OpenIddictConstants. ConsentTypes.Implicit,  // ✅ 不需要用户确认
            displayName: "External Systems Application", 
            secret:  externalSystemsSecret,  // ✅ 必须配置 Secret
            grantTypes: new List<string>
            {
                OpenIddictConstants.GrantTypes.ClientCredentials  // ✅ 客户端凭证模式(推荐)
            },
            scopes: externalScopes, 
            redirectUri: null, 
            clientUri: null
        );
    }
        
    }
}

public virtual async Task SeedAsync(DataSeedContext context)
{
    await CreateScopesAsync();
    await CreateApplicationsAsync();

    //主动给admin角色添加全部权限
    var allPermissions =(await _permissionDefinitionManager.GetPermissionsAsync()).Select(p => p.Name);
    await _permissionDataSeeder.SeedAsync("R","admin",allPermissions);
}

执行迁移

OpenIddict 使用 4 张核心表:

表名作用主要字段
OpenIddictApplications存储客户端应用配置ClientId, ClientSecret, Type, Permissions
OpenIddictScopes定义权限范围Name, DisplayName, Resources
OpenIddictAuthorizations记录用户授权Subject(用户ID), ApplicationId, Scopes
OpenIddictTokens存储 TokenType, Payload, ExpirationDate

表关系

OpenIddictApplications(客户端)
    ↓ 1: N
OpenIddictAuthorizations(授权记录)
    ↓ 1:N
OpenIddictTokens(Token)

OpenIddictScopes(独立,定义可用的 Scope)

修改Token生效时间

  private void ConfigureAuthentication(ServiceConfigurationContext context)
    {
        context.Services.ForwardIdentityAuthenticationForBearer(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
        context.Services.Configure<AbpClaimsPrincipalFactoryOptions>(options =>
        {
            options.IsDynamicClaimsEnabled = true;
        });

      	//AddOpenIddict的配置
        context.Services.AddOpenIddict().AddServer(options =>
        {
            // ⏱️ Token 生命周期配置,12个小时
            options.SetAccessTokenLifetime(TimeSpan.FromHours(12));
        });
    }

创建权限

//aspnet-core\src\AppCoreServer.Application.Contracts\Permissions
// 权限定义提供者,用于定义模块的权限结构
public class AppCoreServerPermissionDefinitionProvider : PermissionDefinitionProvider
{
    // 重写 Define 方法,在其中定义你的权限结构
    public override void Define(IPermissionDefinitionContext context)
    {
        // 添加权限组(用于归类权限),组名为 "frame"
        var myGroup = context.AddGroup(AppCoreServerPermissions.GroupName);

        // 在权限组中添加一个权限,权限名为 "AppCoreServer.MyPermission1",显示名称为“测试权限”
        myGroup.AddPermission(AppCoreServerPermissions.MyPermission1, L("测试权限"));
    }

    // 本地化方法,将字符串包装成 LocalizableString,用于多语言支持
    private static LocalizableString L(string name)
    {
        return LocalizableString.Create<frameResource>(name);
        // frameResource 是你模块的资源类,用于多语言本地化
    }
}

添加权限

[Authorize(framePermissions.MyPermission1)]
public async Task GetInventory()
{
    await _InventoryRepository.getby();
}


此时可以测试一下swagger Ui,运行后报错401,没有登录,然后此时可以在右上角Authoriz是解锁状态,以frame_Swagger客户端登录,
client_secret:不用输入,如果输入匹配不了,即使输入了登录账户admin密码1q2w3E*也是错误。登录成功后,Authoriz是锁状态,即可访问权限接口。

用户密码登录

postman中参数要使用x-www-form-urlencoded填写

curl --location --request POST 'https://localhost:55319/connect/token' \
--header 'User-Agent: Apifox/1.0.0 (https://apifox.com)' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Accept: */*' \
--header 'Host: localhost:55319' \
--header 'Connection: keep-alive' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_id=AppCoreServer_App' \
--data-urlencode 'scope=AppCoreServer' \
--data-urlencode 'username=admin' \
--data-urlencode 'password=1q2w3E*'

响应

{
  "access_token": "eyJhbGciOiJSUzI1N....",
  "token_type": "Bearer",
  "expires_in": 43199
}

服务端凭证登录

POST /connect/token
Content-Type:  application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=AppCoreServer_ExternalSystems
&client_secret=ExternalSystems_API
&scope=ExternalSystems

防伪令牌

问题 1:ABP 9.0 开始强验证了吗?
答:是的! ABP 9.0 默认强制验证防伪令牌,这是安全性增强的重大变更。

问题 2:是否只接受 RequestVerificationToken 字段?
答:是的! ABP 默认只接受 RequestVerificationToken 作为 Header 名称,x-xsrf-token 是不被识别的。

解决方案
把前端的 x-xsrf-token 改成 RequestVerificationToken 即可!

后端禁用防伪令牌验证

注册到服务运行时中
private static void ConfigureAntiForgery(ServiceConfigurationContext context)
{
    context.Services.Configure<AbpAntiForgeryOptions>(options => { options.AutoValidate = false; });
}

3.3 前端Vite代理+Axios封装

🌈配置Vite

export default defineConfig({
  plugins: [react()],
  server: {
  	port: 5173, // 设置开发服务器的端口为 5173
    cors:true,
    proxy: {
      //url关键字
      '/api': {
  		target: "https://localhost:55555/",  
        //是否跨域
        changeOrigin: true,
        //【注意:当指定的目标地址是https的时候一定要添加上该配置,如果是http则不需要,
        //不然请求会一直报错500且调试前端会提示 http proxy error: Error: self signed certificate in certificate chain vite 代理报错】
        secure: false, // 如果是https接口,需要配置这个参数,禁用 SSL 校验,适用于 HTTPS 的本地开发环境
        ws: true,  // 允许websocket代理
        // 重写配置:可以将请求url重写  ,以下意思是将'/api/app/station'进行重写,留着api加上网址=》
        rewrite: (path) => path.replace(/^\/api/, '/api'),
      }
    },
  },
})


target: import.meta.env.VITE_BASE_URL 
这样的写法目前在config中读取环境变量是失败的
详情可见https://cn.vite.dev

🌈Axios封装

import axios, {AxiosRequestConfig, AxiosResponse} from "axios";
import {stringify} from "qs";  
//npm install qs
//npm install --save-dev @types/qs

// 定义 API 响应数据结构的接口
interface ResponseData {
    errorMessage?: string;
    [key: string]: unknown;  // 可以根据实际需要进行扩展
}

// 定义请求配置接口
export interface RequestConfig extends AxiosRequestConfig {
    requestType?: "form" | "json";  // 定义请求类型
}

const instance = axios.create({
    baseURL: getBaseURL(),
    // timeout: 3000,
    headers: {
        "Content-Type": "application/json"
    }
});


// 请求拦截
instance.interceptors.request.use(
    (config) => {
        // 根据 requestType 设置请求头或对数据进行转换
        if ((config as RequestConfig).requestType === "form") {
            config.headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8";
            config.data = stringify(config.data); // 序列化为表单数据
        } else if ((config as RequestConfig).requestType === "json") {
            config.headers["Content-Type"] = "application/json";
        }
        return config;
    },
    (err) => {
        window.alert(err.message || "请求错误");
        return Promise.reject(err);
    }
);

// 响应拦截
instance.interceptors.response.use(
    (response) => response,
    (err) => {
        const errorMessage = getErrorMessage(err.response);
        window.alert(errorMessage || err.message);
        return Promise.reject(err); // 保证错误被业务捕获
    }
);



export default async function request<T = ResponseData>(
    url: string,
    param: RequestConfig = {}
): Promise<T> {
    const response = await instance<T>(url, {...param});
    return response.data;
}



export const getErrorMessage = (response: AxiosResponse | undefined) => {
    //注意,当使用Vite代理时,response不是null,因为vite代理会抛错500出来
    //代理机制:Vite 使用 Node.js 的 http-proxy 或类似库转发请求。如果目标服务器(https://localhost:55319/)不可达,代理会捕获错误并返回 HTTP 响应(e.g., 500),而不是抛出原始的 ECONNREFUSED 或超时错误。
    if (!response) {
        return "服务器未响应,请检查网络";
    }
    const data = response.data;
    if (!data) {
        switch (response.status) {
            case 401:
                window.alert("登录已过期,请重新登录");
                return window.location.reload();
            case 403:
                return "无此操作权限";
            case 404:
                return "服务端无此资源";
            default:
                return "未知异常,请联系开发人员";
        }
    }
    // 如果后端返回了详细错误信息,可在这里优先返回
    if (data.error.message) {
        return data.error.message;
    }
    return "未知错误";
};

// ✅ 核心修改:开发环境返回空字符串,让请求走相对路径
export function getBaseURL() {
    const env = import.meta.env;
    
    // 开发环境:返回空字符串,走 Vite proxy
    if (env.DEV) {
        return "";  // ✅ 关键改动
    }
    

    if (env.PROD) {
        return `${env.VITE_API_BASE_URL}:${env.VITE_API_PORT}`;
    }
    
    return "";
}

Vite.env 环境变量配置

操作

手动创建以下文件
.env   		开发环境
.env.production   生产环境

文件手动新增变量
{
    VITE_API_BASE_URL=https://localhost
    VITE_API_PORT =44319
}

解释

环境加载优先级
一份用于指定模式的文件(例如 .env.production)会比通用形式的优先级更高(例如 .env)。
为了防止意外地将一些环境变量泄漏到客户端,只有以 VITE_ 为前缀的变量才会暴露给经过 vite 处理的代码



console.log(import.meta.env) //此时根据不同的环境,使用不同的环境变量
==》{
    "BASE_URL": "/",
    "DEV": true,  //开发环境
    "MODE": "development",
    "PROD": false,
    "SSR": false,
    "VITE_API_BASE_URL": "https://localhost",
    "VITE_API_PORT": "44319",
}

Vite 在一个特殊的 import.meta.env 对象上暴露环境变量,这些变量在构建时会被静态地替换掉。这里有一些在所有情况下都可以使用的内建变量:
import.meta.env.MODE: {string} 应用运行的模式。

import.meta.env.BASE_URL: {string} 部署应用时的基本 URL。他由base 配置项决定。

import.meta.env.PROD: {boolean} 应用是否运行在生产环境(使用 NODE_ENV='production' 运行开发服务器或构建应用时使用 NODE_ENV='production' )。

import.meta.env.DEV: {boolean} 应用是否运行在开发环境 (永远与 import.meta.env.PROD相反)。

import.meta.env.SSR: {boolean} 应用是否运行在 server 上。

3.4 Openapi与Swagger

配置Swagger/json

private static void ConfigureSwaggerServices(ServiceConfigurationContext context, IConfiguration configuration)
    {
        context.Services.AddAbpSwaggerGenWithOAuth(
            configuration["AuthServer:Authority"]!,
            new Dictionary<string, string>
            {
                    {"AppCoreServer", "AppCoreServer API"}
            },
            options =>
            {
                options.SwaggerDoc("v1", new OpenApiInfo { Title = "AppCoreServer API", Version = "v1" });
                options.DocInclusionPredicate((docName, description) => true);
                options.CustomSchemaIds(type => type.FullName);
                
                //给Swagger的json中添加  operationId字段
                //可见Swashbuckle  Abp.Swashbuckle.Extensions
                options.CustomOperationIds(apiDesc =>
                    apiDesc.TryGetMethodInfo(out var methodInfo) ? methodInfo.Name : null);
            });
    }


//得到的API_json==>

"/api/abp/application-configuration": {
      "get": {
        "tags": [
          "AbpApplicationConfiguration"
        ],
        "operationId": "GetAsync", //方便前端生成方法名称
        "parameters": [
          {
            "name": "IncludeLocalizationResources",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          }
        ]

配置Umijs/Openapi

1.14.1版

创建\reactWeb\openapi2ts.config.ts文件

import { generateService } from '@umijs/openapi';
generateService({
    /**
     * 指定请求库导入语句,生成的接口文件会用到这个导入。
     * 例如你项目中封装了请求函数 request,可以写成:
     * "import { request } from '@/utils/request'"
     */
    requestLibPath: "../../utils/request",

    /**
     * OpenAPI / Swagger 的接口文档地址或本地文件路径。
     * 这里是本地接口文档的 HTTP 地址,生成代码会基于这个定义。
     */
    schemaPath: 'https://localhost:2001/swagger/v1/swagger.json',

    /**
     * 项目名称,生成的文件夹名或命名空间参考名,
     * 主要用于区分不同项目的生成代码。
     */
    serversPath: "./src/services",
    projectName: 'api',

    /**
     * TypeScript 代码中使用的命名空间名称,
     * 生成的类型、接口等都会放在这个命名空间下。
     */
    namespace: 'API',

    /**
     * 自定义模板文件夹路径,指向你的 Nunjucks 模板目录。
     * 生成器会使用此目录下的模板文件生成代码,
     * 方便你自定义生成格式。
     */  
	templatesFolder: './openapi-template', 

    /**
     * 是否使用驼峰命名法生成接口方法名称,默认 true。
     * 设为 false,接口方法名保持 PascalCase,
     * 比如 GetAsync 而不是 getAsync。
     */
    isCamelCase: false,

    /**
     * 钩子函数集,可以自定义生成过程的各种逻辑,
     * 这里自定义了接口函数名称生成逻辑,
     * 使用 OpenAPI 中定义的 operationId 作为函数名,
     * 如果没有则默认 'AutoGenerated'。
     */
    hook: {
        customFunctionName(api) {
            return api.operationId || 'AutoGenerated';
        },
    },
});

templatesFolder: './openapi-template',

需要使用自定义模板,解决默认模板生成的入参参数类型集合,重复不兼容的问题

配置运行命令

"scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "openapi2ts": "tsx openapi2ts.config.ts"
  },

//有需要安装以下两个依赖包
//pnpm i tsx@3.14.0
//pnpm add tslib

执行

此时可以看到 serversPath: "./src/services"
生成了后端对应的请求文件,API的格式是按照requestLibPath: "../../utils/request"设计
    
 /** 此处后端没有提供注释 GET /api/abp/application-configuration */
export async function GetAsync(
  // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
  params: API.GetAsyncParams,
  options?: { [key: string]: any }
) {
  return request<API.ApplicationConfigurationDto>(
    "/api/abp/application-configuration",
    {
      method: "GET",
      params: {
        ...params,
      },
      ...(options || {}),
    }
  );
}   

3.5全局配置与路由与布局

修改html主结构

AppServer\react-web\index.html

修改项目主入口html,标题等为自己项目log等

svg:https://www.iconfont.cn/

UI库

以下使用了
"antd
"antd-style"
"@ant-design/icons"
"@ant-design/pro-components"

构建路由

React Router 主页 | React Router - React Router 路由库

npm i react-router-dom   ^7.11.0
使用数据模式
包名适用场景优点缺点推荐度(Vite 项目)
react-router底层逻辑,非 Web 项目灵活、可扩展缺少 DOM 功能低(通常不单独用)
react-router-dom标准 Web SPA 路由简单、稳定、功能齐全缺少 SSR/SSG 等高级功能高(基础搭建)
@react-router/dev现代框架模式(v7)SSR、SSG、数据加载、Vite 集成配置复杂,生态较新中高(需要高级功能)
构建主路由链

创建react-web\src\router

//react-web\src\router\index.ts

const index:ReturnType<typeof createBrowserRouter> = createBrowserRouter([
    {
        path: "/",
        element: React.createElement(lazy(() => import("../view/ServerApp/Dashboard"))),
    },
]);

export default index;
                                  
                                                                    
const Dashboard:React.FC= ()=>{
    return(
        <>
            <p>我是仪表盘</p>
        </>
    )
};

export default Dashboard;
注册路由
const App: React.FC = () => {
    return(
        <StrictMode>
            <RouterProvider router={router}/>
        </StrictMode>
    )
}
export default App


//此时按照上述路由链,则优先展示仪表盘页面
路由加载页面
const App: React.FC = () => {
    return (
        <StrictMode>
            <Suspense fallback={<PageLoading/>}>
                <RouterProvider router={MainRouter}/>
            </Suspense>
        </StrictMode>
    )
}
export default App
基础页面
创建404页面
//npm antd-style  创建样式

//react-web\src\view\System\NotFound\index.tsx
import {useNavigate} from "react-router-dom";
import React from "react";
import {Button, Result} from "antd";
import useStyles from "./style.ts";

const NotFound: React.FC = () => {
    const {styles} = useStyles();
    const  navigate=useNavigate();
    return (
        <Result
            className={styles.result}
            icon={null}
            title="当前页面不存在..."
            subTitle="请检查您输入的网址是否正确,或点击下面的按钮返回上一级"
            extra={<Button type="primary" onClick={()=>navigate(-1)}>返回上一级</Button>}
        />

    );
};
export default NotFound;
//react-web\src\view\System\NotFound\style.ts

import {createStyles} from "antd-style";
import notFoundImg from "../../../assets/404.png"

const useStyles = createStyles({
    result:{
        position: 'relative',
        backgroundImage: `url(${notFoundImg})`,  // 动态引入图片路径
        backgroundSize: 'cover',         // 确保图片覆盖整个区域
        backgroundPosition: 'center',    // 居中显示背景图片
        backgroundRepeat: 'no-repeat',   // 防止图片重复
        textAlign: "center",             // 文本居中
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',        // 垂直居中
        alignItems: 'center',            // 水平居中
        height: '100vh',
    },
})
export  default  useStyles;
加载页面
import React from "react";
import {Spin} from "antd";
const PageLoading:React.FC=()=>{
    const contentStyle: React.CSSProperties = {
        padding: 50,
        background: 'rgba(0, 0, 0, 0.05)',
        borderRadius: 4,
    };

    return (

        <div
            style={{
                height: "100vh", // 占满视口高度
                display: "flex", // 启用 flex 布局
                justifyContent: "center", // 水平居中
                alignItems: "center", // 垂直居中
                flexDirection: "column", // 子元素纵向排列
                backgroundColor: "#f0f2f5", // 设置背景色 antd 默认一致可选)
            }}
        >
            <Spin tip="Loading" size="large">
                <div style={contentStyle}/>
            </Spin>
        </div>
    );
}

export default PageLoading;

全局样式

//删除index.css,App.css文件
//AppServer\react-web\src\styles
//AppServer\react-web\src\styles\GlobalStyle.ts


import { createGlobalStyle } from "antd-style";

const GlobalStyle = createGlobalStyle(
    ({ theme }) => `
html,body{
    height:100%;
    width:100%;
    background-color:${theme.colorBgLayout};
}
body{
margin:0;
}
`,
);

export default GlobalStyle;
const App: React.FC = () => {
    return (
        <StrictMode>
            <Suspense fallback={<PageLoading/>}>
                <RouterProvider router={MainRouter}/>
            </Suspense>
                <GlobalStyle />
        </StrictMode>
    )
}
export default App

页面布局

添加业务路由
import React, {lazy} from "react";
import {WindowsFilled} from "@ant-design/icons";
import {MenuDataItem} from "@ant-design/pro-components";
import {Navigate} from "react-router-dom";

const ServiceRouters: MenuDataItem[] = [
    {
        path: "dashboard",
        name: "主页",
        element: React.createElement(lazy(() => import("../view/ServerAppPages/Dashboard"))),
    },
    {
        path: "systemManager",
        name: "系统管理",
        icon: React.createElement(WindowsFilled),
        children: [
            {
                path: "ChangeLogs",
                name: "更新日志",
                element: (
                    React.createElement(lazy(() => import("../view/ServerAppPages/ChangeLogs")))
                ),
            }
        ]
    },
    { //页面重定向
        index:true,
        Component: () =>
            React.createElement(Navigate, {
                to: "/dashboard",
            }),
    },
];

export default ServiceRouters;

ProLayout
//https://procomponents.ant.design/components/layout

import {
    LogoutOutlined
} from '@ant-design/icons';
import {PageContainer, ProSettings} from '@ant-design/pro-components';
import {
    ProLayout,
} from '@ant-design/pro-components';
import {
    Button,
    Dropdown,
} from 'antd';
import HeartSvg from "../../../../public/log.svg";
import {Link, Outlet, useLocation, useNavigate} from "react-router-dom";
import React, {Suspense, useState} from "react";
import PageLoading from "../../System/PageLoading";
import serviceRouters from "@/router/ServiceRouters.ts";


const Layout: React.FC = () => {
    const [settings] = useState<Partial<ProSettings>>({
        fixSiderbar: true,
        layout: "mix",
        splitMenus: false,
        navTheme: "light",
        contentWidth: "Fluid",
        colorPrimary: "#FAAD14",
        siderMenuType: "sub",
        fixedHeader: true,
    });
    const username ="admin";
    const navigate = useNavigate();
    const location = useLocation();
    return (
        <ProLayout
            {...settings} //设置属性样式配置
            logo={
                <img
                    src={HeartSvg}
                    alt="logo"
                    style={{
                        height: 24,
                        width: "auto",
                        objectFit: "contain",
                        verticalAlign: "middle",
                    }}
                />
            }
            title={"博客"}
            menuDataRender={() => serviceRouters} //传入路由生成侧边菜单栏
            //头部标题,默认antd图标
            //面包屑,根据传递的路由名
            breadcrumbRender={(routes = []) => {
                return routes.map((route) => {
                    return {
                        path: route.path,
                        breadcrumbName: route.breadcrumbName,
                    };
                });
            }}
            //左侧边栏样式设置
            siderMenuType={"sub"}
            //左侧边栏底部样式
            menuFooterRender={(props) => {
                if (props?.collapsed) return undefined;
                return (
                    <div style={{ textAlign: 'center', paddingBlockStart: 12 }}>
                        <div>© 驰名商标</div>
                    </div>
                );
            }}
            //目前不明确
            location={location}
            //路由点击事件
            menuItemRender={(item, dom) => {
                if (item.path) {
                    return <Link to={`${item.path}`}>{dom}</Link>;
                }
                return dom;
            }}
        >
            {/* 渲染选中的页面内容 */}
            <Suspense fallback={<PageLoading />}>
                <PageContainer>
                    <Outlet />
                </PageContainer>
            </Suspense>
        </ProLayout>
    );
};
export default Layout;
配置主路由
const MainRouter: ReturnType<typeof createBrowserRouter> = createBrowserRouter([
    {
        path:"/",
        element: React.createElement(lazy(() => import("../view/System/Layout"))),
        children: serviceRouters
    },
    {
        path: "*",    // 使用path: "*",匹配配置全局404
        element: (
            React.createElement(lazy(() => import("../view/System/NotFound")))
        ),
    }

]);
                             
                            
理解
应用启动时,通过路由配置以 `path: "/"` 优先加载 `Layout` 组件,作为整体框架的容器。

`Layout` 组件内部预先配置了业务路由(`serviceRouters`),将其转换为侧边栏菜单,侧边栏中的链接如 `<a href="/dashboard">主页</a>` 。

用户首次访问 `/` 时,通过路由重定向跳转到 `/dashboard`,确保进入系统后默认显示主页内容。

整体流程是:`Layout` 负责框架结构(侧边栏、头部、页脚),业务路由负责具体页面内容渲染,实现了框架与业务的分离与解耦。

应用挂载点只挂载全局路由配置,维护简洁,易于扩展和维护,以此分开主路由和业务路由

拓展

路径优化

vite-tsconfig-paths

它的作用就是: 自动读取你的 tsconfig.jsonjsconfig.json 里的路径别名配置,并在 Vite 中生效,无需手动在 vite.config.ts 中写 alias

效果:
import MainRouter from "../../router/MainRouter.ts";
import MainRouter from "@/router/MainRouter.ts";
  1. 安装依赖
npm install vite-tsconfig-paths --save-dev
  1. 配置 vite.config.ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [
    react(),
    tsconfigPaths(), // 👈 自动读取 tsconfig.json 中的 paths
  ],
});
  1. 设置 tsconfig.json 中的路径别名
// tsconfig.app.json
{
  "compilerOptions": {
   
    //引用路径替代
    "baseUrl": ".",  // 设置基准路径为项目根目录
    "paths": {
      "@/*": ["src/*"]  // 现在可以工作
    },
  }
}

优点

  • 不需要手动写 resolve.alias
  • 支持多个别名,如 "@components/*": ["src/components/*"]
  • tsc / IDE / ESLint / Jest 保持一致性。