Blazor Server项目里,集成一个富文本编辑器

前言

用近期在做的项目里,遇到一个需求,要实现一个内容管理模块,类似传统的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项目里了,明天就是国庆假期了,祝大家节日快乐,节后再见!

相关推荐
文心快码BaiduComate2 小时前
文心快码已接入GLM-4.6模型
前端·后端·设计模式
RoyLin2 小时前
C++ 原生扩展、node-gyp 与 CMake.js
前端·后端·node.js
Fency咖啡3 小时前
Spring Boot 3.x 开发 Starter 快速上手体验,通过实践理解自动装配原理
java·spring boot·后端
南方者3 小时前
【JAVA】【BUG】Java 开发中常见问题的具体示例,结合代码片段说明问题场景及原因
java·后端·debug
寻月隐君3 小时前
Rust 泛型编程基石:AsRef 和 AsMut 的核心作用与实战应用
后端·github
Java水解3 小时前
100道互联网大厂面试题+答案
java·后端·面试
用户8356290780513 小时前
使用Python自动化移除Excel公式,保留纯净数值
后端·python
Java水解4 小时前
SpringBoot 线程池 配置使用详解
spring boot·后端
karry_k4 小时前
生产者-消费者问题
后端