博客要有文章展示,首先得有发文章的地方。因此我们在做完登录功能之后,接着现在就开始做文章发布功能了。

文章发布功能包含了2块内容,一块是文章的创建,另一块是分类的创建。

文章发布页面

我们在template文件夹下创建一个article文件夹,并在里面新建一个publish.html:

{% include "partial/header.html" %}
<div class="layui-container">
    <div class="publish">
        <div class="layui-form">
            <input type="hidden" name="id" value="{{article.Id}}">
            <div class="layui-form-item">
                <label class="layui-form-label">文章标题label>
                <div class="layui-input-block">
                    <input type="text" name="title" value="{{article.Title}}" autocomplete="off" class="layui-input">
                div>
            div>
            <div class="layui-form-item">
                <label class="layui-form-label">文章分类label>
                <div class="layui-input-block">
                    <input type="text" name="category_name" value="{{article.Category.Title}}" autocomplete="off"
                           class="layui-input" list="category_name">
                    <datalist id="category_name">
                        {% for item in categories %}
                        <option value="{{item.Title}}">option>
                        {% endfor %}
                    datalist>
                div>
            div>
            <div class="layui-form-item">
                <label class="layui-form-label">关键词label>
                <div class="layui-input-block">
                    <input type="text" name="keywords" value="{{article.Keywords}}" placeholder="多个请用英文,隔开" autocomplete="off" class="layui-input">
                div>
            div>
            <div class="layui-form-item">
                <label class="layui-form-label">文章描述label>
                <div class="layui-input-block">
                    <textarea name="description" placeholder="默认提取文章前150个字" class="layui-textarea" rows="3">{{article.Description}}textarea>
                div>
            div>
            <div class="layui-form-item">
                <label class="layui-form-label">文章内容label>
                <div class="layui-input-block">
                    <textarea name="content" class="layui-textarea" id="text-editor">{{article.ArticleData.Content}}textarea>
                div>
            div>
            <div class="layui-form-item">
                <div class="layui-input-block">
                    <button class="layui-btn" lay-submit lay-filter="article-publish">确认提交button>
                    <button type="reset" class="layui-btn layui-btn-primary">重置button>
                div>
            div>
        div>
    div>
div>
{% include "partial/footer.html" %}

文章发布页面我们需要有如下字段:

  • 文章ID id 这是一个隐藏的字段,因为它不能被编辑和更改。如果没有id或id为0的时候,默认认为这是一篇新文章。
  • 文章标题 title 文章标题即是文章显示的标题。
  • 文章分类 category_name 这里面的文章分类采用分类名称来显示,我们规定每个分类的名称不一样,如果相同的分类名称,我们认为它是同一个分类。这里还通过datalist标签,来支持下拉显示曾经使用过的分类名称,可以直接选中使用。
  • 文章关键词 keywords 文章关键词主要是为了做seo优化使用的,关键词一般是文章标题的部分文字。当需要设置多个关键词时一般使用英文逗号,隔开。
  • 文章简介 description 文章简介我们使用textarea多行输入框来显示,可以简单的介绍这边文章。一般文字不超过150个。如果不填写的话,则程序要自动从文章内容中抽取前150字作为文章简介。
  • 文章内容 content 文章内容使用富文本编辑框来展示,为了简便,我们之间使用layui框架的layedit编辑器。这个编辑器简单,但功能不是太多,先凑合着使用。

鉴定提交表单是js

html页面往往是需要结合js来达到更多的交互目的的。我们也一样,需要使用layui的form表单组件来监听页面表单信息的提交来完成表单信息处理。因为我们的发布页面还需要使用富文本编辑器layedit来支持文章内容可视化编辑。因此我们在app.js 文件中,添加实例化编辑器和表单提交监听代码:

//实例化layui编辑器
if($('#text-editor').length) {
		editorIndex = layedit.build('text-editor', {
				height: 450
		});
}
//发布文章
form.on('submit(article-publish)', function(data){
		let postData = data.field;
		postData.id = Number(postData.id)
		if(!postData.title) {
				return layer.msg("请填写文章标题");
		}
		//同步编辑器内容
		layedit.sync(editorIndex);
		postData.content = $('#text-editor').val();
		$.post("/article/publish", postData, function (res) {
				if(res.code === 0) {
						layer.alert(res.msg, function(){
								window.location.href = "/article/" + res.data.id;
						});
				} else {
						layer.msg(res.msg);
				}
		}, 'json');
		return false;
});

我们上面的html代码中,给填写文章内容的textarea标签,定义了#text-editorid,因此我们使用layedit.build('text-editor',{height: 450})来初始化编辑器,并将编辑器的高度初始化为450px高。这样编辑器就初始化完成了。

监听提交表单的时候,需要做一些简单处理,首先是将id转换成整形postData.id = Number(postData.id),再判断有没有填写文章标题,如果不填写文章标题,则不允许提交。没有文章标题嘛,不能作为独立的文章来提交,否则文章列表展示的时候,没东西展示,用户就会点击不到。

这里还有一个很重要的一步,因为编辑器不是自动同步的,我们还需要在提交前,手动同步一遍编辑器的内容到textarea,并重新获取textarea输入框的内容,才能提交到后台,否则数据可能不是最新的。

post表单提交到后台后,根据后端返回的状态,来判断是否发布成功,如果发布成功,则弹出一个alert窗口,提示发布成功了,并在用户点击确定的时候,跳到文章详情页面中去。

文章发布页面控制器

我们还需要将页面和控制器绑定。因此,我们在controller目录下,创建article.go文件,并添加ArticlePublish(ctx iris.Context)方法:

func ArticlePublish(ctx iris.Context) {
	//发布必须登录
	if !ctx.Values().GetBoolDefault("hasLogin", false) {
		InternalServerError(ctx)
		return
	}

	id := uint(ctx.URLParamIntDefault("id", 0))
	if id > 0 {
		article, _ := provider.GetArticleById(id)

		ctx.ViewData("article", article)
	}

	ctx.View("article/publish.html")
}

发布页面需要做权限判断,这里我们通过ctx.Values().GetBoolDefault("hasLogin", false)获取前面中间件Auth(ctx iris.Context)设置的hasLogin值,如果hasLogin值为true表示登录了,否则认为没有登录。没有登录的时候,我们使用InternalServerError(ctx)服务器错误控制器来显示内容。

在发布页面呈现之前,我们先判断页面路径中,是否包含有文章id,如果有,我们认为这是在修改文章,则先从数据库中读出文章来,并注入到页面中。

id := uint(ctx.URLParamIntDefault("id", 0))
if id > 0 {
    article, _ := provider.GetArticleById(id)

    ctx.ViewData("article", article)
}

接着我们通过ctx.View("article/publish.html")来关联文章发布页面。

这里我们使用了provider.GetArticleById(id),这个函数是需要访问数据库读取文章内容的,因此我们将它抽离到provider目录中。我们打开provider/article.go,在里面添加GetArticleById()函数:

func GetArticleById(id uint) (*model.Article, error) {
	var article model.Article
	db := config.DB
	err := db.Where("`id` = ?", id).First(&article).Error
	if err != nil {
		return nil, err
	}
	//加载内容
	article.ArticleData = &model.ArticleData{}
	db.Where("`id` = ?", article.Id).First(article.ArticleData)
	//加载分类
	article.Category = &model.Category{}
	db.Where("`id` = ?", article.CategoryId).First(article.Category)

	return &article, nil
}

这里我们从数据库根据文章id读取article信息的时候,先使用Preload("ArticleData")来将文章的内容表信息也关联的读进来。

我们设置article模型的时候,category不是和文章表通过外键关联,因此我们需要单独将文章分类加载进来。

文章发布处理逻辑控制器

文章发布控制器和文章页面已经准备好了,我们还需要有一个处理逻辑的控制器。

我们先定义一个接收前端提交文章内容字段的结构体。我们在request文件夹中,新增一个article.go文件,并添加如下代码:

package request

type Article struct {
	Id           uint   `form:"id"`
	Title        string `form:"title" validate:"required"`
	CategoryName string `form:"category_name" validate:"required"`
	Keywords     string `form:"keywords"`
	Description  string `form:"description"`
	Content      string `form:"content" validate:"required"`
	File         string `form:"file"`
}

这里面的字段均为前端页面提交上来的字段,这里不做更多的解释。其中多出一个File字段,是因为layedit编辑器中,图片上传部分包含了一个隐藏的file表单,导致提交的时候,它也会跟过来。如果我们在这里不定义它,则使用Article结构体读取提交的内容的时候,它会报错。所以在这里定义,但实际上我们并没有使用它。

我们接着在article.go中,添加ArticlePublishForm(ctx iris.Context)函数:

func ArticlePublishForm(ctx iris.Context) {
	//发布必须登录
	if !ctx.Values().GetBoolDefault("hasLogin", false) {
		ctx.JSON(iris.Map{
			"code": config.StatusFailed,
			"msg":  "登录后方可操作",
		})
		return
	}
	var req request.Article
	if err := ctx.ReadForm(&req); err != nil {
		ctx.JSON(iris.Map{
			"code": config.StatusFailed,
			"msg":  err.Error(),
		})
		return
	}

	var category *model.Category
	var err error
	//检查分类
	if req.CategoryName != "" {
		category, err = provider.GetCategoryByTitle(req.CategoryName)
		if err != nil {
			category = &model.Category{
				Title:       req.CategoryName,
				Status:      1,
			}
			err = category.Save(config.DB)
			if err != nil {
				ctx.JSON(iris.Map{
					"code": config.StatusFailed,
					"msg":  err.Error(),
				})
				return
			}
		}
	}

	var article *model.Article
	if req.Id > 0 {
		article, err = provider.GetArticleById(req.Id)
		if err != nil {
			ctx.JSON(iris.Map{
				"code": config.StatusFailed,
				"msg":  err.Error(),
			})
			return
		}
		if article.ArticleData == nil {
			article.ArticleData = &model.ArticleData{}
		}
		article.ArticleData.Content = req.Content
	} else {
		article = &model.Article{
			Title:       req.Title,
			Keywords:    req.Keywords,
			Description: req.Description,
			Status:      1,
			ArticleData: &model.ArticleData{
				Content: req.Content,
			},
		}
	}
	//提取描述
	if req.Description == "" {
		htmlR := strings.NewReader(req.Content)
		doc, err := goquery.NewDocumentFromReader(htmlR)
		if err == nil {
			textRune := []rune(strings.TrimSpace(doc.Text()))
			if len(textRune) > 150 {
				article.Description = string(textRune[:150])
			} else {
				article.Description = string(textRune)
			}
		}
	}
	if category != nil {
		article.CategoryId = category.Id
	}
	err = article.Save(config.DB)
	if err != nil {
		ctx.JSON(iris.Map{
			"code": config.StatusFailed,
			"msg":  err.Error(),
		})
		return
	}

	ctx.JSON(iris.Map{
		"code": config.StatusOK,
		"msg":  "发布成功",
		"data": article,
	})
}

逻辑处理控制器不需要页面输出,所以这里不需要使用ctx.View()方法。这里面我们使用ctx.JSON()方法,来输出json字符串。

同样地,文章发布逻辑处理控制器也需要进行权限判断,如果没有登录,则用json返回提示登录:

if !ctx.Values().GetBoolDefault("hasLogin", false) {
    ctx.JSON(iris.Map{
        "code": config.StatusFailed,
        "msg":  "登录后方可操作",
    })
    return
}

这里一定要注意,这里ctx.JSON()输出内容后,我们需要阻断后续的运行,一定要使用return来阻止下面的代码继续执行,否则它会继续执行下去,导致逻辑错误。

上面我们将前端提交的内容读入到了req中:

var req request.Article
	if err := ctx.ReadForm(&req); err != nil {
		ctx.JSON(iris.Map{
			"code": config.StatusFailed,
			"msg":  err.Error(),
		})
		return
	}

接着,根据提交上来的分类名称,我们需要先判断这个分类名称是否已经创建了,如果没有创建,则先创建分类到数据库中。

分类准备后之后,我们再创建article模型,根据提交的数据检查是否包含有id,如果有,则表示这是一条更新记录,从数据库中读取源文章信息,如果没有id,则创建一个新的文章。

var article *model.Article
	if req.Id > 0 {
		article, err = provider.GetArticleById(req.Id)
		if err != nil {
			ctx.JSON(iris.Map{
				"code": config.StatusFailed,
				"msg":  err.Error(),
			})
			return
		}
		if article.ArticleData == nil {
			article.ArticleData = &model.ArticleData{}
		}
		article.ArticleData.Content = req.Content
	} else {
		article = &model.Article{
			Title:       req.Title,
			Keywords:    req.Keywords,
			Description: req.Description,
			Status:      1,
			ArticleData: &model.ArticleData{
				Content: req.Content,
			},
		}
	}

这里要注意下,我们的文章模型中,ArticleData字段是一个外键字段,它关联article_data表,我们通过在这里赋值,它会将内容自动插入到article_data表中。

接着检查前端提交的文章简介有没有内容,如果没有的话,我们则使用goquery包来将html标签过滤掉,只保留纯文字内容,同时将空格清理掉,再截取前150个字作为文章简介。因为涉及到汉字,一个汉字占3-4个字节,不能直接从字符串中截取,我们需要先将字符串转换成rune类型,否则会导致截取的结果不准确导致乱码,字数也不对。

//提取描述
if req.Description == "" {
    htmlR := strings.NewReader(req.Content)
    doc, err := goquery.NewDocumentFromReader(htmlR)
    if err == nil {
        textRune := []rune(strings.TrimSpace(doc.Text()))
        if len(textRune) > 150 {
            article.Description = string(textRune[:150])
        } else {
            article.Description = string(textRune)
        }
    }
}

接着我们在判断提交的信息中有没有分类,如果有分类,则将这篇文章关联到分类中,我们将分类的id赋值给文章的CategoryId字段:

if category != nil {
    article.CategoryId = category.Id
}

最后就是文章的入库过程了:

err = article.Save(config.DB)

这个save方法为文章模型的内置方法,因此,我们需要在model/article.go文件中,添加这个方法:

func (article *Article) Save(db *gorm.DB) error {
	if article.Id == 0 {
		article.CreatedTime = time.Now().Unix()
	}

	if err := db.Debug().Save(article).Error; err != nil {
		return err
	}
	if article.ArticleData != nil {
		article.ArticleData.Id = article.Id
		if err := db.Debug().Save(article.ArticleData).Error; err != nil {
			return err
		}
	}

	return nil
}

这个方法很简单,先是判断这是不是一个新文章,如果是,则添加上发布时间,同时更新文章的更新时间。因为每次保存,我们都认为这是一次更新。最后调用db.Save()方法,来完成文章的入库。

配置文章发布路由

上面文章发布页面和文章逻辑处理都准备好了,我们现在还不能直接访问到发布页面。因此我们还需要给它添到路由中。我们打开route/base.go文件,在Register中添加发布文章的路由:

article := app.Party("/article", controller.Inspect)
{
    article.Get("/publish", controller.ArticlePublish)
    article.Post("/publish", controller.ArticlePublishForm)
}

同样的,我们使用了路由分组功能,将article归纳为一个组。这里面,我们将文章发布页面和文章逻辑处理路径都设置为同一个路径,但是他们分别绑定不同的请求方法,当我们使用get请求访问/article/publish的时候,它是发布页面,当使用post请求访问/article/publish的时候,则是文章发布逻辑处理控制器来处理了。

至此,我们就可以访问到发布页面和正常发布文章了。当然前提是先登录。

验证结果

我们重启一下项目,我们先在浏览器中访问http://127.0.0.1:8001/admin/login来完成登录,接着在浏览器中访问http://127.0.0.1:8001/article/publish看看效果,验证下文章发布过程是否正常。如果不出意外可以看到这样的画面:

 design-publish

完整的项目示例代码托管在GitHub上,需要查看完整的项目代码可以到github.com/fesiong/goblog 上查看,也可以直接fork一份来在上面做修改。