前/后端技术栈
服务端:
makefile
ABP Framework v9.3.0
ORM:
EFCore v9.3.0
-SqlServer V2022
环境:
.NET SDK 9.0
工具:
.ABP CLI 9.3.0
客户端:
makefile
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
ruby
//ABP官网
https://abp.io/docs/latest/get-started/layered-web-application
🌈使用ABP_CLI创建
c#
//手脚架说明
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文件夹
项目结构
bash
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
ruby
前端React+Vite手脚架
//React官网
https://zh-hans.react.dev/learn/creating-a-react-app
//Vite官网
https://vitejs.cn/vite5-cn/
🌈使用Vite创建
js
//使用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 快很多
项目结构
csharp
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日志
c#
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
{
//#省略...
}
}
解析
vbnet
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 项目根配置文件
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 项目迁移配置
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
json
//aspnet-core\src\AppCoreServer.HttpApi.Host\Properties\launchSettings.json
运行配置文件,用于开发时的端口监听配置,常用于编辑器配置启动编译,部署时忽略.
"applicationUrl": "https://localhost:44239"//地址跟App:SelfUrl保持一致
| 启动方式 | 使用配置 | 底层宿主 | 启动行为 |
|---|---|---|---|
IIS Express |
iisExpress.applicationUrl |
IIS Express 进程 (iisexpress.exe) |
通过 VS 或命令调用 iisexpress.exe 启动项目,不是用 dotnet run |
Kestrel(dotnet run) |
applicationUrl |
Kestrel 服务器 (dotnet) |
使用 dotnet run 启动程序,即调用 .dll 文件运行 |
2.2 React
🌈调整:
json
//新建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:
swift
<StrictMode> 是 React 提供的一种工具,用来检测应用中的潜在问题。它不会渲染任何可见的 UI,只是对其子组件执行额外的检查和警告,帮助开发者编写更健壮的代码。
主要功能
检测废弃的生命周期方法:例如 componentWillMount、componentWillReceiveProps。
识别不安全的操作:比如 findDOMNode 的使用。
检查意外的副作用:React 会在开发环境下对组件的 render 和 useEffect 等进行两次调用,以帮助识别潜在的副作用。
强制更严格的模式:提醒开发者采用 React 推荐的最佳实践。
3.基础构建
3.1 后端跨域
🌈操作
c#
"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授权认证
配置客户端
json
//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"
}
}
}
}
创建客户端
c#
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
);
}
}
}
c#
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 | 存储 Token | Type, Payload, ExpirationDate |
表关系:
markdown
OpenIddictApplications(客户端)
↓ 1: N
OpenIddictAuthorizations(授权记录)
↓ 1:N
OpenIddictTokens(Token)
OpenIddictScopes(独立,定义可用的 Scope)
修改Token生效时间
c#
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));
});
}
创建权限
c#
//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 是你模块的资源类,用于多语言本地化
}
}
添加权限
c#
[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填写
shell
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*'
响应
json
{
"access_token": "eyJhbGciOiJSUzI1N....",
"token_type": "Bearer",
"expires_in": 43199
}
服务端凭证登录
ini
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=AppCoreServer_ExternalSystems
&client_secret=ExternalSystems_API
&scope=ExternalSystems
防伪令牌
css
问题 1:ABP 9.0 开始强验证了吗?
答:是的! ABP 9.0 默认强制验证防伪令牌,这是安全性增强的重大变更。
问题 2:是否只接受 RequestVerificationToken 字段?
答:是的! ABP 默认只接受 RequestVerificationToken 作为 Header 名称,x-xsrf-token 是不被识别的。
解决方案
把前端的 x-xsrf-token 改成 RequestVerificationToken 即可!
后端禁用防伪令牌验证
c#
注册到服务运行时中
private static void ConfigureAntiForgery(ServiceConfigurationContext context)
{
context.Services.Configure<AbpAntiForgeryOptions>(options => { options.AutoValidate = false; });
}
3.3 前端Vite代理+Axios封装
🌈配置Vite
tsx
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封装
tsx
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 环境变量配置
操作
ini
手动创建以下文件
.env 开发环境
.env.production 生产环境
文件手动新增变量
{
VITE_API_BASE_URL=https://localhost
VITE_API_PORT =44319
}
解释
typescript
环境加载优先级
一份用于指定模式的文件(例如 .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
c#
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文件
tsx
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',
需要使用自定义模板,解决默认模板生成的入参参数类型集合,重复不兼容的问题
配置运行命令
json
"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
执行
json
此时可以看到 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主结构
tsx
AppServer\react-web\index.html
修改项目主入口html,标题等为自己项目log等
svg:https://www.iconfont.cn/
UI库
bash
以下使用了
"antd
"antd-style"
"@ant-design/icons"
"@ant-design/pro-components"
构建路由
React Router 主页 | React Router - React Router 路由库
css
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
ts
//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;
注册路由
tsx
const App: React.FC = () => {
return(
<StrictMode>
<RouterProvider router={router}/>
</StrictMode>
)
}
export default App
//此时按照上述路由链,则优先展示仪表盘页面
路由加载页面
tsx
const App: React.FC = () => {
return (
<StrictMode>
<Suspense fallback={<PageLoading/>}>
<RouterProvider router={MainRouter}/>
</Suspense>
</StrictMode>
)
}
export default App
基础页面
创建404页面
tsx
//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;
ts
//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;
加载页面
tsx
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;
全局样式
ts
//删除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;
tsx
const App: React.FC = () => {
return (
<StrictMode>
<Suspense fallback={<PageLoading/>}>
<RouterProvider router={MainRouter}/>
</Suspense>
<GlobalStyle />
</StrictMode>
)
}
export default App
页面布局
添加业务路由
ts
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
tsx
//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;
配置主路由
ts
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")))
),
}
]);
理解
tex
应用启动时,通过路由配置以 `path: "/"` 优先加载 `Layout` 组件,作为整体框架的容器。
`Layout` 组件内部预先配置了业务路由(`serviceRouters`),将其转换为侧边栏菜单,侧边栏中的链接如 `<a href="/dashboard">主页</a>` 。
用户首次访问 `/` 时,通过路由重定向跳转到 `/dashboard`,确保进入系统后默认显示主页内容。
整体流程是:`Layout` 负责框架结构(侧边栏、头部、页脚),业务路由负责具体页面内容渲染,实现了框架与业务的分离与解耦。
应用挂载点只挂载全局路由配置,维护简洁,易于扩展和维护,以此分开主路由和业务路由
拓展
路径优化
它的作用就是: 自动读取你的 tsconfig.json 或 jsconfig.json 里的路径别名配置 ,并在 Vite 中生效,无需手动在 vite.config.ts 中写 alias!
javascript
效果:
import MainRouter from "../../router/MainRouter.ts";
import MainRouter from "@/router/MainRouter.ts";
- 安装依赖
css
npm install vite-tsconfig-paths --save-dev
- 配置 vite.config.ts
javascript
// 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
],
});
- 设置 tsconfig.json 中的路径别名
json
// tsconfig.app.json
{
"compilerOptions": {
//引用路径替代
"baseUrl": ".", // 设置基准路径为项目根目录
"paths": {
"@/*": ["src/*"] // 现在可以工作
},
}
}
优点
- 不需要手动写
resolve.alias。 - 支持多个别名,如
"@components/*": ["src/components/*"]。 - 和
tsc/ IDE / ESLint / Jest 保持一致性。