前言
用近期在做的项目里,遇到一个需求,要实现一个内容管理模块,类似传统的CMS,需要在后台可以方便的发布一些图文混排的信息。
传统的方式,我们有很多成熟的选择,富文本编辑器,Markdown编辑器,甚至是一些商业版的类office产品,比如石墨文档,腾讯文档等等。
而到了BlazorServer的环境,情况稍微有点不一样,当然可能因为我对Blazer还不太熟,所以集成起来还是颇费了一些功夫,好在结果不错,这里简单分析一下。
选型
我这里使用的是国内团队开发的AIEditor(aieditor.dev/),当然上面提到的那几个方向,在Blazor框架里都是适用的。AIEditor吸引我的点,主要还是国内团队开发(我们有ToG的项目,要考虑这些),而且社区版功能强大,颜值也高,还能方便的和AI大模型做集成,最终就选择了这个插件。
这里呢,Blazor原生的选择有一个Blazored TextEditor,基于QuillJs(quilljs.com/)实现,生态蛮好。还有mvc时代常用的tinyMCE也原生支持Blazor,而且接入非常方便,但有调用限制。
还有个tiptap(tiptap.dev/),提供一个"无头"的富文本,高度自定义,文档也丰富。
这些都是非常不错的方向,大家看情况选择就好。
开始接入
在BlazorServer项目里接入富文本编辑器的流程和传统MVC项目差不多,都需要准备号文件上传相关的接口。
而BlazorServer和传统项目不同的是,BlazorServer的通信方式是SignalR,前后台的逻辑代码都是C#,当然也可以和JS语言互相调用,但操作起来还是稍微麻烦一点。而且,上传这个操作,我们还需要在BlazorServer项目里提供一个http接口,或者你已经有现成的文件上传服务,如果没有,就需要改动一下。
接下来我聊一下我的接入流程
引入文件
首先,到官方网站下载最新的社区版插件:github.com/aieditor-te...
下载好后,在项目里创建一个JS文件,导入插件
javascript
import { AiEditor } from '/assets/plugin/aieditor/dist/index.js';
初始化配置
这里的初始化配置,直接参照文档就好,我这里就不多说,直接贴代码
javascript
export function initAiEditor(helper, sign, admin) {
dotNetHelper = helper;
uploadSign = sign;
declarationAdmin = admin;
const headers = {
"tmp": Math.floor(Date.now() / 1000),
"sign": uploadSign,
"admin": declarationAdmin
};
aieditor = new AiEditor({
element: "#aiEditor",
placeholder: "点击输入内容...",
content: 'AiEditor 是一个面向 AI 的下一代富文本编辑器。',
image: {
// 后面说
},
video: {
// 后面说
},
file: {
// 后面说
},
ai: {
// 后面说
}
});
}
简单说一下,我这里初始化的时候接受了几个参数,主要是后面上传的时候,做认证用的,可以先忽略。
初始化插件后,我这里分别配置了,图片,视频和文件的上传,以及AI模块,具体内容下面再说。js配置完成后,razor组件里也要做相应配置
csharp
@page "/test/richtext"
@implements IAsyncDisposable
@using System.Threading.Tasks
@using Magic.Declaration.Infrastructure.Security
@using Magic.Declaration.Manage.Services
<MudCard>
<div id="aiEditor" style="height: 550px; margin: 20px"></div>
<div id="fileInfo" class="mt-2 text-muted"></div>
@if (showUploadProgress)
{
<MudProgressLinear @bind-Value="uploadProgress" Color="Color.Primary" />
<MudText Typo="Typo.caption" Class="mt-1">上传中:@($"{uploadProgress:F1}%")</MudText>
}
</MudCard>
<MudCard>
<MudButtonGroup Color="Color.Primary" Variant="Variant.Outlined">
<MudButton>写入</MudButton>
<MudButton OnClick="ReadContent">读取</MudButton>
</MudButtonGroup>
</MudCard>
@inject IJSRuntime JSRuntime
@code {
private IJSObjectReference? jsmodule;
private DotNetObjectReference<RichText>? dotNetRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
dotNetRef = DotNetObjectReference.Create(this);
jsmodule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./assets/js/aieditor.js");
await jsmodule.InvokeVoidAsync("initAiEditor", dotNetRef, UploadSign, Base62Codec.Encode(userName));
}
}
// 资源释放相关
public async ValueTask IAsyncDisposable.DisposeAsync()
{
if (jsmodule is not null)
{
try
{
await jsmodule.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
}
}
public void Dispose()
{
dotNetRef?.Dispose();
}
}
这部分核心的意思就是配置.net和js环境的互操作,更具体的说明,大家可以参考微软官方的介绍👉:learn.microsoft.com/zh-cn/aspne...
界面的布局可以忽略哈,这本身就是个测试页面
这样我们加载页面就能看到这个富文本界面了

文件上传方法
文件上传这里,我是封装了2个底层的方法,一个是接收大文件,一个是接收小文件,然后上层分出了不同的接口,分别接收图片,视频和其他文件
小文件上传(图片)
小文件的上传相对来说比较容易,主要就是验证上传的内容是否合法,然后按实际业务要求保存文件就好
csharp
/// <summary>
/// 上传文件
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
public async Task<FileResponseDto> UploadFile(FileRequestDto dto)
{
var result = await InspectFile(dto);
if (!result.IsSuccess)
{
return result;
}
//文件保存逻辑,可以根据情况分别保存到本地,或者对象存储服务器,比如MinIO,RustFS,OSS等
return result;
}
这里我的报错逻辑不列了,主要说一下那个文件检测。
文件检测我这里是引入了一个第三方库,FileSignatures(github.com/neilharvey/...),用它来验证文件是否被篡改或者修改了后缀名,常见的场景是,有的用户把jpg图片,改个后缀改成pdf就把文件传上来了,蒙混过关,这种情况要检查文件魔数是否和后缀相符,而FileSignatures提供了简洁的接口,可以很方便的识别这种情况。
大概的检测代码如下
csharp
public async Task<FileResponseDto> InspectFile(FileRequestDto dto)
{
//前置验证,判空之类,忽略
//这里上层服务传入一个允许的文件类型,比如jpg,pdf,doc等
Dictionary<string, string> requirementFormatDic = dto.RequirementFormatDic;
if (dto.RequirementFormatDic == null)
{
result.ErrMsg = "文件类型错误";
return result;
}
FileFormat? format;
using (var stream = dto.File.OpenReadStream())
{
// 构造函数注入的_fileFormatInspector
format = _fileFormatInspector.DetermineFileFormat(stream);
if (format == null || !requirementFormatDic.ContainsKey(format.Extension))
{
result.ErrMsg = "非法格式的文件";
return result;
}
if (!requirementFormatDic[format.Extension].Equals(format.MediaType, StringComparison.InvariantCultureIgnoreCase))
{
result.ErrMsg = "非法格式的文件,请不要手动调整文件扩展名";
return result;
}
if (!format.Extension.Equals(Path.GetExtension(dto.File.FileName).Trim('.'), StringComparison.InvariantCultureIgnoreCase))
{
result.ErrMsg = "非法格式的文件,请不要手动调整文件扩展名";
return result;
}
// 上传之后验证文件的签名是否和入参里提供的签名一致
string hashValue = HashAlgorithm.GetMD5HashFromStream(stream);
if (!hashValue.Equals(dto.FileMd5, StringComparison.InvariantCultureIgnoreCase))
{
result.ErrMsg = "文件被篡改";
return result;
}
string signatureString = BitConverter.ToString(format.Signature.ToArray());
return new FileResponseDto()
{
//一个业务dto,略
};
}
}
这样,就可以检测出非法的,篡改的文件了。
然后上层在封装一个单独针对文件的方法供接口调用
csharp
public async Task<FileResponseDto> UploadImage(HttpContext context)
{
//构造允许的图片格式,也可以在配置里读取哈~
Dictionary<string, string> formatDic = new Dictionary<string, string>
{
{ "png" , "image/png" },
{ "jpg" , "image/jpeg"},
{ "webp" , "image/webp"},
{ "tiff" , "image/tiff"},
{ "jpeg" , "image/jpeg"}
};
var dto = new FileRequestDto
{
//其他业务参数
RequirementFormatDic = formatDic,
};
return await UploadFile(dto);
}
这样文件上传的方法就准备好了。
大文件上传(视频,压缩包,文件)
大文件上传的方法稍微复杂,我这里也简单聊一下思路,主要是接收前端传过来的小的分片文件,接收完成后要做一次文件合并,这里的逻辑比较复杂,代码很多,我就不灌了。
这种的案例应该很多,笔者曾在几年前写过一篇相关的博客:xie.infoq.cn/article/0ee...,大家自行实现就好。
上传接口
上传方法准备完成后,就可以准备http接口了,而我们的项目本身是基于BlazorServer,不是WebApi,所以我这里使用的最小API的方式(MinimalAPI)简单实现了几个端点,专供上传使用。
MinimalAPI是微软在.net 6以后推出的,性能比常规的Controller要好一些,可以参考官方的介绍learn.microsoft.com/zh-cn/aspne...
我这里是上传接口如下
csharp
public static void MapUploadEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/upload");
group.MapPost("/image", async (HttpContext context, FileHandler fileHandler) =>
{
try
{
// 鉴权
var checkResult = await fileHandler.CheckHeader(context);
if (!checkResult.IsSuccess)
{
return Results.Json(UploadResult.Error(checkResult.ErrMsg));
}
var result = await fileHandler.UploadImage(context);
if (!result.IsSuccess)
{
return Results.Json(UploadResult.Error(result.ErrMsg));
}
return Results.Json(UploadResult.Success(
new
{
src = result.RelativeUrl,
alt = result.FileName
}, result.ErrMsg));
}
catch (Exception ex)
{
return Results.Json(UploadResult.Error($"服务端异常:{ex.Message}"));
}
});
group.MapPost("/video", async (HttpContext context, FileHandler fileHandler) =>
{
try
{
int FileIndex = Convert.ToInt32(context.Request.Form["fileIndex"]);
if (FileIndex == 0) {
var checkResult = await fileHandler.CheckHeader(context);
if (!checkResult.IsSuccess)
{
return Results.Json(UploadResult.Error(checkResult.ErrMsg));
}
}
var dic = new Dictionary<string, string>()
{
{ "mp4" , "video/mp4" },
{ "mov" , "video/mov"},
{ "3gp" , "video/3gpp"},
};
var result = await fileHandler.UploadFileChuck(context, dic);
if (!result.IsSuccess)
{
return Results.Json(UploadResult.Error(result.ErrMsg));
}
return Results.Json(UploadResult.Success(
new
{
src = result.RelativeUrl,
//poster = "https://localhost:7040/play.png"
poster=""
}, result.ErrMsg));
}
catch (Exception ex)
{
return Results.Json(UploadResult.Error($"服务端异常:{ex.Message}"));
}
});
group.MapPost("/file", async (HttpContext context, FileHandler fileHandler) =>
{
try
{
int FileIndex = Convert.ToInt32(context.Request.Form["fileIndex"]);
if (FileIndex == 0)
{
var checkResult = await fileHandler.CheckHeader(context);
if (!checkResult.IsSuccess)
{
return Results.Json(UploadResult.Error(checkResult.ErrMsg));
}
}
var dic = new Dictionary<string, string>()
{
{ "zip" , "application/zip"},
{ "bmp" , "image/bmp"},
{ "pdf" , "application/pdf"},
{ "xls" , "application/vnd.ms-excel" },
{ "xlsx" , "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
{ "doc" , "application/msword"},
{ "docx" , "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
{ "ppt" , "application/vnd.ms-powerpoint"},
{ "pptx" , "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
{ "visio" , "application/vnd.visio"}
};
var result = await fileHandler.UploadFileChuck(context, dic);
if (!result.IsSuccess)
{
return Results.Json(UploadResult.Error(result.ErrMsg));
}
return Results.Json(UploadResult.Success(
new
{
href = result.RelativeUrl,
//poster = "https://localhost:7040/play.png"
name = result.FileName
}, result.ErrMsg));
}
catch (Exception ex)
{
return Results.Json(UploadResult.Error($"服务端异常:{ex.Message}"));
}
});
}
然后在Program中调用
csharp
app.MapUploadEndpoints();
至此,服务端的代码就基本完成了
注意,这里我省略了一个鉴权的方法(CheckHeader)没有列代码,这个还是跟业务相关,大家根据实际场景实现即可。
此外,三个接口返回值也略有不同,主要是AIEditor接收的数据格式不一样,这个参照官方文档即可。
编辑器配置
刚才我们引入插件的时候,留空了文件相关的配置,现给出文档地址,大家可以参照:aieditor.dev/docs/zh/con...,这个是图片模块的配置,视频和文件的类似,主要是返回值不同。
我这里也以图片的上传为例
javascript
// AiEditor其他配置,略
image: {
allowBase64: false,
uploadUrl: uploadBaseUrl + 'image',
uploadFormName: "image",
uploadHeaders: headers,
uploader: createFileUploader('image')
},
// 其他...
然后实现一个uploader的方法
javascript
function createFileUploader(formName) {
return async (file, uploadUrl, headers) => {
const md5 = await calculateMD5(file);
const formData = new FormData();
formData.append("业务参数1", 0);
formData.append("业务参数2", "test");
formData.append(formName, file);
formData.append("fileMd5", md5);
const resp = await fetch(uploadUrl, {
method: "POST",
credentials: 'same-origin',
headers: { 'Accept': 'application/json', ...headers },
body: formData
});
const json = await resp.json();
if (!resp.ok) throw new Error(json.message || '上传失败');
return json;
};
}
这里有一个计算md5的方法,我是引入了SparkMd5这个库
javascript
function calculateMD5(file) {
const CHUNK_SIZE = 2 * 1024 * 1024; //2Mb一个块,按需调整
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const spark = new SparkMD5.ArrayBuffer();
return new Promise((resolve, reject) => {
let index = 0;
function readChunk() {
if (index >= totalChunks) {
return resolve(spark.end());
}
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const reader = new FileReader();
reader.onload = (e) => {
spark.append(e.target.result);
index++;
readChunk();
};
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(chunk);
}
readChunk();
});
}
上传效果
上面配置完成后,上传模块就可以正常工作了,看一下几种文件的上传案例
传图片

传视频

传文件

*AI配置
aieditor最大的一个特性是原生在编辑器里接入了大模型,虽然社区版可选择的平台有限,但从使用场景上来说,已经非常够用了。
我这里是集成了一个星火大模型,大家可以分别参照编辑器的文档和星火大模型的文档,我这里给出个案例。
接入的星火的时候,主要是在服务端生成一个授权链接,这个星火的文档里有相信说明,直接参考即可,没什么可说的。(www.xfyun.cn/doc/spark/W...)
下面这段代码,就是参照官方的sdk代码,几乎没怎么修改,
csharp
private static string GetAuthUrl()
{
string date = DateTime.UtcNow.ToString("r");
Uri uri = new Uri(hostUrl);
StringBuilder builder = new StringBuilder("host: ").Append(uri.Host).Append("\n").//
Append("date: ").Append(date).Append("\n").//
Append("GET ").Append(uri.LocalPath).Append(" HTTP/1.1");
string sha = HMACsha256(api_secret, builder.ToString());
string authorization = string.Format("api_key=\"{0}\", algorithm=\"{1}\", headers=\"{2}\", signature=\"{3}\"", api_key, "hmac-sha256", "host date request-line", sha);
//System.Web.HttpUtility.UrlEncode
string NewUrl = "https://" + uri.Host + uri.LocalPath;
string path1 = "authorization" + "=" + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(authorization));
date = date.Replace(" ", "%20").Replace(":", "%3A").Replace(",", "%2C");
string path2 = "date" + "=" + date;
string path3 = "host" + "=" + uri.Host;
NewUrl = NewUrl + "?" + path1 + "&" + path2 + "&" + path3;
return NewUrl;
}
然后后文件上传一样,创建一个简单的接口,传回这个授权链接
csharp
public static void MapSparkEndPoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/spark");
group.MapGet("chat", (HttpContext context) =>
{
return Results.Json(new
{
errorCode = 0,
url = GetAuthUrl()
});
});
}
然后在aieditor的AI模块配置
javascript
ai: {
models: {
spark: {
appId: "你的appId",
version: "你用的版本"
}
},
onCreateClientUrl: (modelName, modelConfig, startFn) => {
fetch("/api/spark/chat")//请求接口
.then(resp => resp.json())
.then(json => startFn(json.url));
},
onTokenConsume(_, __, count) {
sparkTokenUsed += count;
}
}
这样就完成了
看一下效果

结束语
至此AIEditor就成功接入到我们的Blazor项目里了,明天就是国庆假期了,祝大家节日快乐,节后再见!