# 一、企业网站后台内容管理(网站的新闻、产品、内容等等信息)表 news

# 1、表设计

企业网站后台内容管理(网站的新闻、产品、内容等等信息)表 news 字段设计(更多字段根据业务需求来扩展)

表名:news

字段名 数据类型 描述 默认值

字段含义

id int(20) 主键、自增长、UNSIGNED无符号
category_id int(20) 0 外键,关联哪个分类,属于哪个分类下面的信息内容
name varchar(100) 内容标题、新闻标题、产品名称等
enname varchar(255) 标题、名称等英文
status int(1) 1 内容显示状态 0不显示 1显示 等等状态
order int(11) 50 文章、内容排序,默认50
description varchar(255) 内容摘要,同时也是百度等搜索引擎抓取的描述信息
keywords varchar(255) 百度等搜素引擎的关键字
maincontent text 主体内容(可为空,比如当对产品描述时候仅仅说一下产品名称内容不想填)
attachment varchar(255) 内容附件,比如:封面图、文件等等,或者指明文章出自哪个网址链接,一般是网址
lookcount int(11) 0 文章、内容点击量,默认0
manager_id int(20) 0 哪个发布的内容,一般关联管理员表,外键
timestamp datetime CURRENT_TIMESTAMP 时间戳(可用于统计内容发布数量等)
...
create_time datetime CURRENT_TIMESTAMP 数据创建时间
update_time datetime CURRENT_TIMESTAMP 数据更新时间

额外说明:mysql每行最大只能存65535个字节。假设是utf-8编码,每个字符占3个字节。varchar存储最大字符数为(65535-2-1)/3=21844字符长度

# 2、创建迁移文件、执行迁移命令创建数据表 news

在数据库创建数据表的方式很多,在上面定义了表字段之后,可以使用phpmyAdmin数据库插件执行sql语句创建等等方式,但是建议大家通过:创建迁移文件、执行迁移命令创建数据表。

涉及的知识点:

  1. 章节2:egg.js基础-五、eggjs项目中sequelize模型创建mysql数据库

创建迁移文件 命令:

npx sequelize migration:generate --name=init-news

创建迁移文件:

'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up(queryInterface, Sequelize) {
    const { INTEGER, STRING, DATE, ENUM, TEXT, BIGINT } = Sequelize;
    // 创建表 --- 类似我们sql语句定义表结构
    await queryInterface.createTable('news', {
      id: {
        type: INTEGER(20).UNSIGNED,
        primaryKey: true,
        autoIncrement: true,
        comment: '内容主键id'
      },
      category_id: {
        type: INTEGER(20).UNSIGNED,
        allowNull: false,
        defaultValue: 0,
        comment: '分类id',
        references: { //关联关系
          model: 'category', //关联的表
          key: 'id' //关联表的主键
        },
        onDelete: 'cascade', //删除时操作
        onUpdate: 'restrict', // 更新时操作
      },
      name: {
        type: STRING(100),
        allowNull: false,
        defaultValue: '',
        comment: '内容标题、新闻标题、产品名称等'
      },
      enname: {
        type: STRING,
        allowNull: true,
        defaultValue: '',
        comment: '标题、名称等英文'
      },
      status: {
        type: INTEGER(1),
        allowNull: false,
        defaultValue: 1,
        comment: '内容显示状态 0不显示 1显示 等等状态'
      },
      order: {
        type: INTEGER,//不限定长度.默认int(11)
        allowNull: true,
        defaultValue: 50,
        comment: '文章、内容排序,默认50'
      },
      description: {
        type: STRING,//不限定长度.默认varchar(255)
        allowNull: true,
        defaultValue: '',
        comment: '内容摘要'
      },
      keywords: {
       type: STRING,//不限定长度.默认varchar(255)
        allowNull: true,
        defaultValue: '',
        comment: '关键字'
      },
      maincontent: {
        type: TEXT,
        allowNull: true,
        defaultValue: '',
        comment: '主体内容(可为空,比如当对产品描述时候仅仅说一下产品名称内容不想填)'
      },
      attachment: {
        type: STRING,//不限定长度.默认varchar(255)
        allowNull: true,
        defaultValue: '',
        comment: '内容附件,比如:封面图、文件等等,或者指明文章出自哪个网址链接,一般是网址'
      },
      lookcount: {
        type: INTEGER,
        allowNull: true,
        defaultValue: 0,
        comment: '文章、内容点击量,默认0'
      },
      manager_id: {
        type: INTEGER(20).UNSIGNED,
        allowNull: false,
        defaultValue: 0,
        comment: '哪个发布的内容,一般关联管理员表,外键',
        references: { //关联关系
          model: 'manager', //关联的表
          key: 'id' //关联表的主键
        },
        onDelete: 'cascade', //删除时操作
        onUpdate: 'restrict', // 更新时操作
      },
      timestamp : {
        type: DATE, 
        allowNull: false, 
        defaultValue:Sequelize.fn('NOW'),
        comment: '时间戳(可用于统计内容发布数量等)',
      },
      create_time: { type: DATE, allowNull: false, defaultValue: Sequelize.fn('NOW') },
      update_time: { type: DATE, allowNull: false, defaultValue: Sequelize.fn('NOW') }
    });
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('news')
  }
};

执行迁移文件命令生成数据库表:

// 升级数据库-创建数据表
npx sequelize db:migrate
// 如果有问题需要回滚,可以通过 `db:migrate:undo` 回退一个变更
npx sequelize db:migrate:undo
// 可以通过 `db:migrate:undo:all` 回退到初始状态
npx sequelize db:migrate:undo:all

# 3、创建 news表 的模型

模型文件主要是用于处理数据库表的增删改查等操作 app/model/news.js

'use strict';

module.exports = app => {
    const { INTEGER, STRING, DATE, ENUM, TEXT, BIGINT } = app.Sequelize;

    const News = app.model.define('news', {

        id: {
            type: INTEGER(20).UNSIGNED,
            primaryKey: true,
            autoIncrement: true,
            comment: '内容主键id'
        },
        category_id: {
            type: INTEGER(20).UNSIGNED,
            allowNull: false,
            defaultValue: 0,
            comment: '分类id',
            references: { //关联关系
                model: 'category', //关联的表
                key: 'id' //关联表的主键
            },
            onDelete: 'cascade', //删除时操作
            onUpdate: 'restrict', // 更新时操作
        },
        name: {
            type: STRING(100),
            allowNull: false,
            defaultValue: '',
            comment: '内容标题、新闻标题、产品名称等'
        },
        enname: {
            type: STRING,
            allowNull: true,
            defaultValue: '',
            comment: '标题、名称等英文'
        },
        status: {
            type: INTEGER(1),
            allowNull: false,
            defaultValue: 1,
            comment: '内容显示状态 0不显示 1显示 等等状态'
        },
        order: {
            type: INTEGER,//不限定长度.默认int(11)
            allowNull: true,
            defaultValue: 50,
            comment: '文章、内容排序,默认50'
        },
        description: {
            type: STRING,//不限定长度.默认varchar(255)
            allowNull: true,
            defaultValue: '',
            comment: '内容摘要'
        },
        keywords: {
            type: STRING,//不限定长度.默认varchar(255)
            allowNull: true,
            defaultValue: '',
            comment: '关键字'
        },
        maincontent: {
            type: TEXT,
            allowNull: true,
            defaultValue: '',
            comment: '主体内容(可为空,比如当对产品描述时候仅仅说一下产品名称内容不想填)'
        },
        attachment: {
            type: STRING,//不限定长度.默认varchar(255)
            allowNull: true,
            defaultValue: '',
            comment: '内容附件,比如:封面图、文件等等,或者指明文章出自哪个网址链接,一般是网址'
        },
        lookcount: {
            type: INTEGER,
            allowNull: true,
            defaultValue: 0,
            comment: '文章、内容点击量,默认0'
        },
        manager_id: {
            type: INTEGER(20).UNSIGNED,
            allowNull: false,
            defaultValue: 0,
            comment: '哪个发布的内容,一般关联管理员表,外键',
            references: { //关联关系
                model: 'manager', //关联的表
                key: 'id' //关联表的主键
            },
            onDelete: 'cascade', //删除时操作
            onUpdate: 'restrict', // 更新时操作
        },
        timestamp : {
            type: DATE, 
            allowNull: false, 
            defaultValue:app.Sequelize.fn('NOW'),
            get(){
              let data = this.getDataValue('timestamp') ;
              /*
              //如果想转换成年月日时分秒,可以使用moment.js库等其他时间库
              //我们这里带领大家回忆一下js基础,就手动拼接一下
              let year = data.getFullYear();
              let month = ("0" + (data.getMonth() + 1)).slice(-2);
              let day = ("0" + data.getDate()).slice(-2);
              let hours = ("0" + data.getHours()).slice(-2);
              let minutes = ("0" + data.getMinutes()).slice(-2);
              let seconds = ("0" + data.getSeconds()).slice(-2);
              return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
              */
              //如果想转成时间戳
              return (new Date(data)).getTime();
            }
        },
        // sex: { type: ENUM, values: ['男','女','保密'], allowNull: true, defaultValue: '保密', comment: '留言用户性别'},
        create_time: {
            type: DATE,
            allowNull: false,
            defaultValue: app.Sequelize.fn('NOW'),
            get() {
                return app.formatTime(this.getDataValue('create_time'));
            }
        },
        update_time: { type: DATE, allowNull: false, defaultValue: app.Sequelize.fn('NOW') }
    });

    // 模型关联关系
    News.associate = function (models) {
        // 关联分类 反向一对多(一个分类可以有多个新闻内容,分类对于新闻内容是一对多的关系,反过来新闻内容属于分类belongsTo,就是反向一对多)
        News.belongsTo(app.model.Category);
        // 关联管理员 反向一对多(一个管理员可以发布很多新闻,管理员对于新闻内容就是一对多的关系,反过来新闻内容属于管理员belongsTo,就是反向一对多)
        News.belongsTo(app.model.Manager);
    }

    return News;
}

# 4、完成基本的新闻内容列表展示、及创建发布新闻内容界面

控制器 app/controller/admin/news.js

'use strict';

const Controller = require('egg').Controller;

class NewsController extends Controller {

    //内容列表界面
    async index() {
        const { ctx, app } = this;
        //分页:可以提炼成一个公共方法page(模型名称,where条件,其他参数options)
        let data = await ctx.page('News',{},{
            // 关联查询
            include:[
                {
                    model:app.model.Category,// 需要查询的模型
                    attributes:['id','name','enname'],// 查询的字段 
                },
                {
                    model:app.model.Manager,// 需要查询的模型
                    attributes:['id','username','avatar'],// 查询的字段 
                },
            ],
        });
        data = JSON.parse(JSON.stringify(data));
        // console.log(data);return;
        //渲染公共模版
        await ctx.renderTemplate({
            title: '信息内容列表',//现在网页title,面包屑导航title,页面标题
            data,
            tempType: 'table', //模板类型:table表格模板 ,form表单模板
            table: {
                //表格上方按钮,没有不要填buttons
                buttons: [
                    {
                        url: '/admin/news/create',//新增路径
                        desc: '发布内容',//新增 //按钮名称
                        // icon: 'fa fa-plus fa-lg',//按钮图标
                    }
                ],
                //表头
                columns: [
                    {
                        title: '标题/名称',
                        key: 'name',
                        class: 'text-left',//可选
                    },
                    {
                        title: '所属栏目',
                        // key: 'category_id',
                        class: 'text-center',//可选
                        render(item) {
                            return `
                               <span>${item.category.name}</span>
                            `;
                        }
                    },
                    {
                        title: '浏览量',
                        key: 'lookcount',
                        class: 'text-center',//可选
                    },
                    {
                        title: '哪个管理员发布的',
                        // key: 'username',
                        render(item) {
                            return `
            <h2 class="table-avatar">
              <a href="#" class="avatar avatar-sm mr-2">
                  <img
                      class="avatar-img rounded-circle"
                      src="${item.manager.avatar}"
                      alt="User Image"></a>
                  <a href="#"> ${item.manager.username}
                  <span>管理员id:${item.manager.id}</span></a>
            </h2>
           `;
                        },
                    },
                    
                    {
                        title: '创建时间',
                        key: 'create_time',
                        width: 200,//可选
                        class: 'text-center',//可选
                    },
                    {
                        title: '操作',
                        class: 'text-right',//可选
                        action: {
                            //修改
                            edit: function (id) {
                                return `/admin/news/edit/${id}`;
                            },
                            //删除
                            delete: function (id) {
                                return `/admin/news/delete/${id}`;
                            }
                        }
                    },
                ],
            },
        });
    }

    //新增内容界面
    async create() {
        const { ctx, app } = this;

        // 渲染模版前先拿到所有分类
        let data = await ctx.service.category.dropdown_Categorylist();
        console.log(data);
        data.shift();//删除自定义的第一项
        console.log(data);
        //渲染公共模版
        await ctx.renderTemplate({
            title: '发布内容',//现在网页title,面包屑导航title,页面标题
            tempType: 'form', //模板类型:table表格模板 ,form表单模板
            form: {
                //提交地址
                action: "/admin/news/save",
                //  字段
                fields: [
                    {
                        label: '请选择一个分类栏目发布',
                        type: 'dropdown', //下拉框
                        name: 'category_id',
                        default: JSON.stringify(data),
                        placeholder: '请选择',
                    },
                    {
                        label: '标题/名称',
                        type: 'text',
                        name: 'name',
                        placeholder: '请输入标题/名称',
                        // default:'默认值测试', //新增时候默认值,可选
                    },
                    {
                        label: '标题/名称英文',
                        type: 'text',
                        name: 'enname',
                        placeholder: '请输入标题/名称英文,选填',
                    },
                    {
                        label: '内容摘要',
                        type: 'text',
                        name: 'description',
                        placeholder: '请输入内容摘要,选填',
                    },
                    {
                        label: '关键字',
                        type: 'text',
                        name: 'keywords',
                        placeholder: '请输入关键字(逗号隔开),选填',
                    },
                    {
                        label: '主体内容',
                        type: 'text',
                        name: 'maincontent',
                        placeholder: '请输入主体内容,选填',
                    },
                    {
                        label: '内容附件',
                        type: 'text',
                        name: 'attachment',
                        placeholder: '请输入附件如:封面图、文件、网址等,选填',
                    },
                    {
                        label: '点击量',
                        type: 'text',
                        name: 'lookcount',
                        default:0,
                        placeholder: '请输入点击量,选填,默认0',
                    },
                    {
                        label: '排序',
                        type: 'number',
                        name: 'order',
                        placeholder: '请输入排序(数字越小越排在前面)',
                        default:50,
                    },
                    {
                        label: '内容显示状态',
                        type: 'btncheck', //按钮组选择
                        name: 'status',
                        default: JSON.stringify([
                            { value: 1, name: '显示', checked: true },
                            { value: 0, name: '隐藏' },
                        ]),
                        placeholder: '内容显示状态 0不显示 1显示 等等状态',
                    },
                ],
            },
            //新增成功之后跳转到哪个页面
            successUrl: '/admin/news/index',
        });
    }

}

module.exports = NewsController;

路由 app/router/admin/admin.js

//新闻内容列表页面
router.get('/admin/news/index', controller.admin.news.index);
//发布新闻内容界面
router.get('/admin/news/create', controller.admin.news.create);

表单模版微调 app/view/admin/layout/_form.html

...
{# 如果是下拉框类型 #}
<div class="dropdown">
    <button type="button" class="btn dropdown-toggle dropdown-{{item.name}}" data-toggle="dropdown" id="dropdownData">
        {{item.placeholder  if item.placeholder  else '一级分类'}}
    </button>
...

分类控制器调整 app/controller/admin/category.js

//修改分类界面
...
//  字段
fields: [
    {
        label: '放在哪个分类里面',
        type: 'dropdown', //下拉框
        name: 'pid',
        default: JSON.stringify(data),
        placeholder: '不调整(如需调整请选择)',//统一改成placeholder提示
        // id:id,//有id说明操作是修改不是新增
    },
...    

# 5、完成发布新闻内容的数据提交,以及删除单个新闻内容

控制器 app/controller/admin/news.js

//发布新闻内容提交数据
async save() {
    //一般处理流程
    //1.参数验证
    this.ctx.validate({
        name: {
            type: 'string',  //参数类型
            required: true, //是否必须
            // defValue: '', 
            desc: '标题/名称' //字段含义
        },
        enname: {
            type: 'string',
            required: false,
            defValue: '',
            desc: '标题/名称英文'
        },
        status: {
            type: 'int',
            required: true,
            defValue: 1,
            desc: '内容显示状态 0不显示 1显示 等等状态'
        },
        category_id: {
            type: 'int',
            required: true,
            defValue: 0,
            desc: '分类栏目id'
        },
        description: {
            type: 'string',
            required: false,
            defValue: '',
            desc: '内容摘要'
        },
        order: {
            type: 'int',
            required: false,
            defValue: 50,
            desc: '排序'
        },
        keywords: {
            type: 'string',
            required: false,
            defValue: '',
            desc: '关键字'
        },
        maincontent: {
            type: 'string',
            required: false,
            defValue: '',
            desc: '主体内容'
        },
        attachment: {
            type: 'string',
            required: false,
            defValue: '',
            desc: '内容附件'
        },
        lookcount: {
            type: 'int',
            required: false,
            defValue: 0,
            desc: '点击量/浏览量'
        },
    });
    //先判断一下直播功能中的礼物账号是否存在,不存在在写入数据库
    //2.写入数据库
    //3.成功之后给页面反馈
    let { name, enname, status, category_id,description,order,keywords, maincontent,attachment,lookcount} = this.ctx.request.body;
    if (await this.app.model.News.findOne({ 
        where: { 
            name,
            category_id 
        } 
    })) {
        return this.ctx.apiFail('已存在同一个分类的内容,不能重复发布');
    }
    //否则不存在则写入数据库
    //拿到管理员的登录信息
    const manager_id = this.ctx.session.auth.id;
    const res = await this.app.model.News.create({
        name,
        enname,
        status,
        category_id,
        description,
        order,
        keywords, maincontent,attachment,lookcount,
        manager_id
    });
    this.ctx.apiSuccess('发布内容成功');
}

//删除新闻内容
async delete() {
    const { ctx, app } = this;
    const id = ctx.params.id;
    await app.model.News.destroy({
        where: {
            id
        }
    });
    //提示
    ctx.toast('内容删除成功', 'success');
    //跳转
    ctx.redirect('/admin/news/index');
}

路由 app/router/admin/admin.js

//发布新闻内容提交数据
router.post('/admin/news/save', controller.admin.news.save);
//删除新闻内容功能
router.get('/admin/news/delete/:id', controller.admin.news.delete);

表单模版微调 app/view/admin/layout/_form.html

...
methods:{
    submit(){
        ...
        for(const key in this.form){
            let value = this.form[key];
            console.log('key的值:'+ value);
            if(typeof value == 'string' && value.length > 0 && value.indexOf('&quot;')>-1){
                this.form[key] = JSON.parse(value.replaceAll('&quot;','"'))[0].value;
            }
        }
        ...

# 6、发布新闻内容界面的内容摘要换成文本域标签,内容附件换成自定义的按钮组选择附件内容

控制器 app/controller/admin/news.js

//发布新闻内容界面
async create() {
    ...
    {
        label: '内容摘要',
        type: 'textarea',
        name: 'description',
        placeholder: '请输入内容摘要,选填',
    },
    ...
    {
        label: '内容附件',
        type: 'diy',//自定义类型模版
        name: 'attachment',
        placeholder: '请输入内容附件,选填',
        render(item) {
            const name = item.name;
            console.log(name);
            return `
            <div class="btn-group btn-group-${name}">
                <button type="button" class="btn btn-success"
                @click="diyBtnGroup('btn-group-${name}','btn-group-${name}-check',0)">文件</button>
                <button type="button" class="btn btn-light"
                @click="diyBtnGroup('btn-group-${name}','btn-group-${name}-check',1)">网址</button>
            </div>
            <div class="btn-group-${name}-check">
                <div>
                    <input class="form-control" type="file" name="${name}"
                    @change="uploadFile($event,'${name}',(e)=>{
                        console.log(e);
                    })" />
                    <div v-if="uploadedFiles.hasOwnProperty('${name}')">
                        <div v-if="uploadedFiles['${name}'].endsWith('.jpg') || 
                            uploadedFiles['${name}'].endsWith('.jpeg')  ||
                            uploadedFiles['${name}'].endsWith('.png')  ||
                            uploadedFiles['${name}'].endsWith('.gif')  ">
                        <img :src="uploadedFiles['${name}']" class="mt-2 p-1 rounded border avatar-lg" />
                        </div>
                        <div v-else>
                        <span class="text-success">附件上传成功!</span>
                        </div>
                    </div>
                </div>
                <div style="display:none;">
                    <div class="input-group mb-3">
                        <div class="input-group-prepend">
                            <span class="input-group-text">网址</span>
                        </div>
                        <input type="text" class="form-control" placeholder="请输入网址" 
                        name='${name}' v-model="form.${name}" />
                    </div>
                </div>
            </div>
            `;
        }
    },
   ... 
}

表单模版调整 app/view/admin/layout/_form.html

...
{# 如果是文本域类型 #}
{% elif item.type == 'textarea' %}
<textarea class="form-control" rows="5" name="{{item.name}}"
placeholder="{{item.placeholder}}..."
v-model="form.{{item.name}}"></textarea>
{# 如果是自定义类型模版 #}
{% elif item.type == 'diy' %}
{% if item.render %}
{{ item.render(item) | safe }}
{% endif %}
...

methods:{
    ...
    data(){
        return {
            ...
            uploadedFiles: {}, //存储每个item.name对应的已上传文件路径
        }
    },
    ...
    methods:{
        ...
        uploadFile(e,name,callback=null){
            ...
            $.ajax({
                ...
                success:  (response, stutas, xhr)=> {
                    ...
                    // 使用$set将上传成功的文件URL添加到uploadedFiles对象
                    this.$set(this.uploadedFiles, name, response.data.url);
                    if(callback && typeof callback == 'function'){
                        callback({
                            event:e,
                            name:name,
                            data:response.data
                        });
                    }
                }
            });
        },
        ...
        //自定义的按钮点击效果
        diyBtnGroup(clickparentClassname, classname,index){
            $('.' + classname).children().eq(index).show().siblings().hide();
            $('.'+ clickparentClassname).children().eq(index)
            .addClass('btn-success').siblings().removeClass('btn-success').addClass('btn-light');
        }
    }
}

# 二、初步引入富文本编辑器

我们在后台发布文章,文章主体内容一般会用到类似我们word文档形式的编辑器,常用的富文本编辑器有:UEditortinymceKindeditorSimditorCKEditorwangEditorSuneditorfroala等等。大家可以自由选择,我们本套课程采用 tinymce 作为富文本编辑器进行讲解。其他编辑器,感兴趣的同学可以去学习摸索一下,我们目前只需要学会怎么使用即可。

# tinymce 编辑器说明

tinymce 编辑器的文档说明,可查阅: http://tinymce.ax-z.cn/quick-start.php (opens new window)

# 如何使用

控制器 app/controller/admin/news.js

...
{
    label: '主体内容',
    type: 'editor', // 富文本编辑器
    name: 'maincontent',
    placeholder: '请输入主体内容,选填',
},
...

表单模版调整 app/view/admin/layout/_form.html

...
{# 如果是富文本编辑器 #}
{% elif item.type == 'editor' %}
    {# {% include "admin/layout/_editor.html" %} #}
    <script src="/public/editor/tinymce/js/tinymce/tinymce.min.js"></script>
    <textarea id="{{item.name}}" name="{{item.name}}" :value="form.{{item.name}}"
    placeholder="{{item.placeholder }}..." >{{form[item.name] if form[item.name]}}</textarea>
    <script>
        tinymce.init({
            selector: '#{{ item.name }}',
            language: 'zh_CN',
            init_instance_callback: function (editor) {
                // 监听内容改变事件,并更新 Vue 数据模型
                editor.on('Change', () => {
                    console.log('监听内容改变事件', editor.getContent());
                    // 使用 Vue.set 或 $set 方法更新数据模型
                    Vueapp.$set(Vueapp.form, "{{item.name}}", editor.getContent()); 
                });
            }
        });
    </script>
...

关于编辑器的引入:

大家去群里面下载本节课的课件,我给大家提供了编辑器的所有文件,大家将文件放在 app/public/editor里面即可

# 三、富文本编辑器常用配置说明及上传图片

富文本编辑器的各项配置,查阅 http://tinymce.ax-z.cn/general/basic-setup.php (opens new window)

# 常用配置说明及上传图片:

  1. 新建一个模版 app/view/admin/layout/_editor.html
  2. app/view/admin/layout/_form.html 表单模版引入
{# 如果是富文本编辑器 #}
{% elif item.type == 'editor' %}
{% include "admin/layout/_editor.html" %}
  1. app/view/admin/layout/_editor.html 富文本编辑器模版
<script src="/public/editor/tinymce/js/tinymce/tinymce.min.js"></script>
<textarea id="{{item.name}}" name="{{item.name}}" :value="form.{{item.name}}"
    placeholder="{{item.placeholder}}">{{form[item.name] if form[item.name]}}</textarea>
<script>
    tinymce.init({
        selector: '#{{item.name}}',
        language: 'zh_CN',
        branding: false, // 添加此行以移除 Logo
        promotion: false, // 隐藏“Upgrade”按钮等商业推广内容
        
        //toolbar: 
        // 保留默认工具栏,并添加 'image' 按钮
        toolbar: `code undo redo | styleselect | bold italic underline | alignleft aligncenter alignright alignjustify | 
        bullist numlist outdent indent | link imageUpload`,
        
        //菜单栏配置 menu和menubar  [看文档http://tinymce.ax-z.cn/general/basic-setup.php]
        // menubar: false, // 隐藏菜单栏
        
        //plugins: ['image', 'table', 'code'],
        plugins: 'image,table,code',
        //上传图片:http://tinymce.ax-z.cn/general/upload-images.php
        //images_upload_url: '/uploadStreamSingleToServerDiy/adminImg?_csrf={{ctx.csrf|safe}}',
        //自定义上传 images_upload_handler
        //images_upload_handler: function (blobInfo, succFun, failFun) {}, 
        
        setup:(editor)=>{
            console.log(editor.ui.registry);
            editor.ui.registry.addButton("imageUpload",{
                tooltip:"插入图片",
                icon:"image",
                onAction:()=>{
                    console.log('点击了插入图片');
                    // editor.insertContent('<img src="https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png" style="width:100%;" /">');
                    // editor.insertContent('<p><img style="width: 100%;" src="../../public/uploads/Diy/adminImg/20240416/1713253836023_11465325.png"></p>');
                    // 创建一个隐藏的文件输入元素
                    const fileInput = document.createElement('input');
                    fileInput.type = 'file';
                    fileInput.accept = 'image/*'; // 限制仅允许选择图片文件

                    // 监听文件选择事件
                    fileInput.addEventListener('change', (event) => {
                        const file = event.target.files[0];
                        if (file) {
                            try {
                                console.log(file);
                                let formData = new FormData();
                                formData.append('file', file);
                                $.ajax({
                                    type: 'POST', 
                                    url: "/uploadStreamSingleToServerDiy/adminImg?_csrf={{ctx.csrf|safe}}",
                                    processData: false,  // 告诉jQuery不要去处理发送的数据
                                    data: formData,
                                    contentType: false,   // 告诉jQuery不要去设置Content-Type请求头
                                    success:  (response, stutas, xhr)=> {
                                        //console.log(response)
                                        //console.log(response.data.url)
                                        if(response.data.url){
                                            // 使用服务器返回的图片URL插入图片到编辑器内容中
                                            const imgHtml = `<img src="${response.data.url}" />`;
                                            console.log('imgHtml',imgHtml);
                                            editor.insertContent(imgHtml);
                                        }
                                    },
                                    error:function(e){
                                        console.log(e)
                                    }
                                });
                            } catch (error) {
                                console.error('Error occurred during image upload:', error);
                            }
                        }
                    });

                    // 触发文件选择对话框
                    fileInput.click();
                }
            });
            
        },

        init_instance_callback: function (editor) {
            // 监听内容改变事件,并更新 Vue 数据模型
            editor.on('Change',()=>{
                console.log('监听内容改变事件', editor.getContent());
                // 使用 Vue.set 或 $set 方法更新数据模型
                // Vueapp.$set(Vueapp.form,"{{item.name}}",editor.getContent());
                // 修正图片src路径
                const contentWithCorrectedPaths = editor.getContent().
                replace(/(<img[^>]*src=")(\.\.\/)+([^"]*)/,(_, prefix, relativePath, url) => `${prefix}/${url}`);
                console.log('修正图片src路径', contentWithCorrectedPaths);
                // 使用 Vue.set 或 $set 方法更新数据模型
                Vueapp.$set(Vueapp.form, "{{item.name}}", contentWithCorrectedPaths);
            });
        }
    });
</script>

# 完整配置参考

如果想让编辑器的 工具栏菜单栏 更丰富,可以参考一下配置,更多配置查阅编辑器文档 http://tinymce.ax-z.cn/general/basic-setup.php (opens new window)

// 配置
const init = {
    language_url: '/tinymce/langs/zh-Hans.js', // 中文语言包路径
    language: "zh-Hans",
    skin_url: '/tinymce/skins/ui/oxide', // 编辑器皮肤样式
    content_css: "/tinymce/skins/content/default/content.min.css",
    menubar: false, // 隐藏菜单栏
    autoresize_bottom_margin: 50,
    max_height: 500,
    min_height: 450,
    // height: 320,
    toolbar_mode: "none",
    plugins:
        'wordcount visualchars visualblocks template searchreplace save quickbars preview pagebreak nonbreaking media insertdatetime importcss image help fullscreen directionality codesample code charmap link code table lists advlist anchor autolink autoresize autosave',
    toolbar:
        "formats undo redo fontsizeselect fontselect ltr rtl searchreplace media imageUpload | outdent indent aligncenter alignleft alignright alignjustify lineheight underline quicklink h2 h3 blockquote numlist bullist table removeformat forecolor backcolor bold italic strikethrough hr link preview fullscreen help ",
    content_style: "p {margin: 5px 0; font-size: 14px}",
    fontsize_formats: "12px 14px 16px 18px 24px 36px 48px 56px 72px",
    font_formats: "微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方= PingFang SC, Microsoft YaHei, sans- serif; 宋体 = simsun, serif; 仿宋体 = FangSong, serif; 黑体 = SimHei, sans - serif; Arial = arial, helvetica, sans - serif;Arial Black = arial black, avant garde;Book Antiqua = book antiqua, palatino; ",
    branding: false,
    elementpath: false,
    resize: false, // 禁止改变大小
    statusbar: false, // 隐藏底部状态栏
    // 自定义按钮 
    setup: (editor) => { 
        //console.log(editor);
        editor.ui.registry.addButton('imageUpload', { 
            tooltip: '插入图片', 
            icon: 'image', 
            onAction: (e) => {
                console.log('插入图片') 
                // let url = "https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png" 
                // editor.insertContent(`&nbsp;<img src="${url}" style="width:100%;">&nbsp;`) 
            } 
        }) 
    } 
}
tinymce.init; // 初始化

# 四、修改新闻内容

控制器 app/controller/admin/news.js

//修改新闻内容界面
    async edit() {
        const { ctx, app } = this;
        const id = ctx.params.id;
        let currentdata = await app.model.News.findOne({
            where: {
                id
            }
        });
        if (!currentdata) {
            return ctx.apiFail('该内容不存在');
        }
        currentdata = JSON.parse(JSON.stringify(currentdata));
        console.log('当前内容数据', currentdata);
        // return;

        // 渲染模版前先拿到所有分类
        let data = await ctx.service.category.dropdown_Categorylist();
        data.shift();//删除数组第一个元素
        console.log('下拉框显示的所有分类', JSON.stringify(data));
        // return;

        //渲染公共模版
        await ctx.renderTemplate({
            id,
            title: '修改内容',//现在网页title,面包屑导航title,页面标题
            tempType: 'form', //模板类型:table表格模板 ,form表单模板
            form: {
                //修改直播功能中的礼物提交地址
                action: '/admin/news/update/' + id,
                //  字段
                fields: [
                    {
                        label: '请选择一个分类栏目发布',
                        type: 'dropdown', //下拉框
                        name: 'category_id',
                        default: JSON.stringify(data),
                        placeholder: '请选择',
                    },
                    {
                        label: '标题/名称',
                        type: 'text',
                        name: 'name',
                        placeholder: '请输入标题/名称',
                        // default:'默认值测试', //新增时候默认值,可选
                    },
                    {
                        label: '标题/名称英文',
                        type: 'text',
                        name: 'enname',
                        placeholder: '请输入标题/名称英文,选填',
                    },
                    {
                        label: '内容摘要',
                        type: 'textarea',
                        name: 'description',
                        placeholder: '请输入内容摘要,选填',
                    },
                    {
                        label: '关键字',
                        type: 'text',
                        name: 'keywords',
                        placeholder: '请输入关键字(用逗号隔开),选填',
                    },
                    {
                        label: '主体内容',
                        type: 'editor', //富文本编辑器
                        name: 'maincontent',
                        placeholder: '请输入主体内容,选填',
                    },
                    {
                        label: '内容附件',
                        type: 'diy', //自定义类型组件
                        name: 'attachment',
                        placeholder: '请输入内容附件,选填',
                        render(item){
                            const name = item.name;
                            console.log(name);
                            return `
                            <div class="btn-group btn-group-${name}">
                                <button type="button" class="btn btn-success"
                                @click="diyBtnGroup('btn-group-${name}','btn-group-${name}-check',0)">文件</button>
                                <button type="button" class="btn btn-light"
                                @click="diyBtnGroup('btn-group-${name}','btn-group-${name}-check',1)">网址</button>
                            </div>
                            <div class="btn-group-${name}-check">
                                <div>
                                    <input class="form-control" type="file" name="${name}"
                                    @change="uploadFile($event,'${name}',(e)=>{
                                        console.log(e);
                                    })" />
                                    <div v-if="uploadedFiles.hasOwnProperty('${name}') && uploadedFiles['${name}'] ">
                                        <div v-if="uploadedFiles['${name}'].endsWith('.jpg') ||
                                            uploadedFiles['${name}'].endsWith('.jpeg')  ||
                                            uploadedFiles['${name}'].endsWith('.png')  ||
                                            uploadedFiles['${name}'].endsWith('.gif') ">
                                            <img :src="uploadedFiles['${name}']"  class="mt-2 p-1 rounded border avatar-lg" >
                                        </div>
                                        <div v-else>
                                            <span class="text-success">附件上传成功!</span>
                                        </div>
                                    </div>
                                </div>
                                <div style="display:none;">
                                    <div class="input-group mb-3">
                                        <div class="input-group-prepend">
                                           <span class="input-group-text">链接地址</span>
                                        </div>
                                        <input type="text" class="form-control" 
                                        placeholder="请输入链接地址"  name='${name}'
                                        v-model="form.${name}"  />
                                    </div>
                                </div>
                                
                            </div>
                            `;
                        }
                    },
                    {
                        label: '点击量/浏览量',
                        type: 'text',
                        name: 'lookcount',
                        default:0,
                        placeholder: '请输入点击量/浏览量,选填',
                    },
                    {
                        label: '排序',
                        type: 'number',
                        name: 'order',
                        placeholder: '请输入排序',
                        default:50,
                    },
                    {
                        label: '内容显示状态',
                        type: 'btncheck', //按钮组选择
                        name: 'status',
                        default: JSON.stringify([
                            { value: 1, name: '显示', checked: true },
                            { value: 0, name: '隐藏' },
                        ]),
                        placeholder: '内容显示状态 0不显示 1显示 等等状态',
                    },
                ],
                //修改内容默认值
                data:currentdata,
            },
            //修改成功之后跳转到哪个页面
            successUrl: '/admin/news/index',
        });

    }

    //修改新闻内容提交数据
    async update() {
        const { ctx, app } = this;
        //1.参数验证
        this.ctx.validate({
            id: {
                type: 'int',
                required: true,
                desc: '新闻内容id'
            },
            name: {
                type: 'string',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '标题/名称' //字段含义
            },
            enname: {
                type: 'string',
                required: false,
                defValue: '',
                desc: '标题/名称英文'
            },
            status: {
                type: 'int',
                required: true,
                defValue: 1,
                desc: '内容显示状态 0不显示 1显示 等等状态'
            },
            category_id: {
                type: 'int',
                required: true,
                defValue: 0,
                desc: '分类id'
            },
            description: {
                type: 'string',
                required: false,
                defValue: '',
                desc: '内容摘要'
            },
            order: {
                type: 'int',
                required: false,
                defValue: 50,
                desc: '排序'
            },
            keywords: {
                type: 'string',
                required: false,
                defValue: '',
                desc: '关键字'
            },
            maincontent: {
                type: 'string',
                required: false,
                defValue: '',
                desc: '主体内容'
            },
            attachment: {
                type: 'string',
                required: false,
                defValue: '',
                desc: '内容附件'
            },
            lookcount: {
                type: 'int',
                required: false,
                defValue: 0,
                desc: '点击量/浏览量'
            }
        });

        // 参数
        const id = ctx.params.id;
        let { name, enname, status, category_id,description,order,
            keywords,maincontent,attachment, lookcount} = this.ctx.request.body;
        // 先看一下是否存在
        const news = await app.model.News.findOne({ where: { id } });
        if (!news) {
            return ctx.apiFail('该内容记录不存在');
        }
        //存在,内容名称可以一样,只要保证它在不同的分类下面
        const Op = this.app.Sequelize.Op;//拿Op,固定写法
        //先查一下修改的内容名称,是否已经存在,如果存在,但是只要不放在同一分类下还是可以的
        const hasdata = await app.model.News.findOne({
            where: {
                name,
                id: {
                    [Op.ne]: id
                },
            }
        });
        if (hasdata && hasdata.category_id == category_id) {
            return ctx.apiFail('同一个分类下不能有相同的内容名称');
        }
        // 修改数据
        news.name = name;
        news.enname = enname;
        news.status = status;
        news.category_id = category_id;
        news.description = description;
        news.order = order;
        news.keywords = keywords; 
        news.maincontent = maincontent; 
        news.attachment = attachment; 
        news.lookcount = lookcount;  

        await news.save();

        /*
        //update方法批量修改字段
        let params = this.ctx.request.body;
        await news.update(params,{
            //fields:['name'],//指定修改字段
        });
        */
       
        // 给一个反馈
        ctx.apiSuccess('修改内容成功');
    }

路由 app/router/admin/admin.js

//修改新闻内容界面
router.get('/admin/news/edit/:id', controller.admin.news.edit);
//修改新闻内容提交数据
router.post('/admin/news/update/:id', controller.admin.news.update);

细节调整

  1. app/view/admin/layout/_form.html 表单模版对编辑器内容的渲染处理
...
form:{
    {% for item in form.fields %}
        {{item.name}} : `{{ (form.data[item.name]|safe) if form.data[item.name] else  item.default  }}`,
    {% endfor %}
},
...
  1. app/view/admin/layout/_editor.html富文本编辑器模版,对编辑器多张图片都要修正路径,正则表达式加上 g进行全局匹配
...
// 修正图片src路径
const contentWithCorrectedPaths = editor.getContent().
replace(/(<img[^>]*src=")(\.\.\/)+([^"]*)/g,(_, prefix, relativePath, url) => `${prefix}/${url}`);
...

# 五、新闻列表页面调整标题展示、新增是否显示按钮组,并对按钮组方法封装成公共方法

  1. 控制器 app/controller/admin/news.js
//新闻内容列表界面
async index() {
  ...
    {
        title: '标题/名称',
        // key: 'name',
        class: 'text-left',//可选
        render(item) {
            let type = '';
            let imgHTML = '';
            if (item.attachment.endsWith('.jpg') || item.attachment.endsWith('.jpeg') ||
                item.attachment.endsWith('.png') || item.attachment.endsWith('.gif')) {
                type = 'image';
                imgHTML = `
                <a href="#" class="avatar avatar-sm mr-2">
                    <img class="avatar-img" src="${item.attachment}" alt="User Image">
                </a>
                `;
            }
            return `
                <h2 class="table-avatar">
                    ${imgHTML}
                    <a href="#"> 
                        ${item.name.substring(0,20)}
                        <span>${item.description.substring(0,20)}</span>
                    </a>
                </h2>
            `;
        }
    },
  ...
  {
    title: '显示状态',
    key: 'status',
    width: 200,//可选
    class: 'text-center',//可选
    hidekeyData: true,//是否隐藏key对应的数据
    render(item) {
        console.log('显示状态里面每个item', item);
        let arr = [
            { value: 1, name: '显示' },
            { value: 0, name: '隐藏' },
        ];
        let str = `<div class="btn-group btn-group-${item.id}">`;
        for (let i = 0; i < arr.length; i++) {
            str += `<button type="button" class="btn btn-light" data="${item.status}"
            value="${arr[i].value}"
            @click="changeBtnStatus('status','btn-group-${item.id}',${arr[i].value},
            ${i},${item.id},'news','News')">${arr[i].name}</button>`;
        }
        str += `</div>`;
        return str;
    }
},
  ...
}
  1. 同时分类控制器的changeBtnStatus方法也需要修改:app/controller/admin/category.js
//分类列表页面
async index() {
   ...
   {
        title: '可用状态',
        key: 'status',
        width: 200,//可选
        class: 'text-center',//可选
        hidekeyData: true,//是否隐藏key对应的数据
        render(item) {
            console.log('可用状态里面每个item', item);
            let arr = [
                { value: 1, name: '可用' },
                { value: 0, name: '不可用' },
            ];
            let str = `<div class="btn-group btn-group-${item.id}">`;
            for (let i = 0; i < arr.length; i++) {
                str += `<button type="button" class="btn btn-light" data="${item.status}"
                value="${arr[i].value}"
                @click="changeBtnStatus('status','btn-group-${item.id}',${arr[i].value},
                ${i},${item.id},'category','Category')">${arr[i].name}</button>`;
            }
            str += `</div>`;
            return str;
        }
    },
   ...
}
  1. app/view/admin/layout/_table.html 表格模版调整
//点击按钮组
changeBtnStatus(keyname,classname,btnValue,index,id,keytable,model){
    // console.log(keyname,classname,btnValue,index,id,keytable,model);
    $.ajax({
        type: 'POST', 
        // url: "/admin/category/updateStatus?_csrf={{ctx.csrf|safe}}",
        url: `/admin/commonUpdateById?_csrf={{ctx.csrf|safe}}`,
        contentType:'application/json;charset=UTF-8;',
        data:JSON.stringify({
            id:id,
            // status:btnValue
            keyname:keyname,
            keyvalue:btnValue,
            keytable:keytable,
            model:model
        }),
        ...
    });
}
  1. 封装一个公共方法 app/controller/admin/home.js
// 公共方法,根据id修改某张表中的某个字段
async commonUpdateById(){
    const { id, keyname,keyvalue,model} = this.ctx.request.body;
    //1.参数验证
    this.ctx.validate({
        id: {
            type: 'int',
            required: true,
            // defValue: 0,
            desc: '分类id'
        },
        keyname: {
            type: 'string',
            required: true,
        },
        keyvalue: {
            type: 'string',
            required: true,
            defValue: '',
        },
        model: {
            type: 'string',
            required: true,
        },
    });

    // console.log('获取的参数',id, keyname,keyvalue,model);return;
    
    let data = await this.app.model[model].findByPk(id);
    if (!data) {
        return this.ctx.apiFail('数据不存在');
    }
    data[keyname] = keyvalue;
    await data.save();
    this.ctx.apiSuccess('修改成功');
}
  1. 公共方法路由 app/router/admin/admin.js
// 公共方法,根据id修改某张表中的某个字段
router.post('/admin/commonUpdateById', controller.admin.home.commonUpdateById);
更新时间: 2024年4月17日星期三下午4点24分