# 一、企业网站后台栏目管理(网站导航栏,栏目分类)表 category

# 1、网站栏目分析

网站首页
关于我们
    公司介绍
        公司简介
        企业资质
        企业文化
        发展历程
    公司动态
    公司团建
    ...
产品中心
    产品分类
        产品分类1
            产品分类1-1
            产品分类1-2
        产品分类2
            产品分类2-1
            产品分类2-2
        产品分类3    
        ...
    上市产品
    研发产品
    ...
新闻中心
    公司新闻
    行业新闻
    ...
人才招聘
    招聘职位
    招聘要求
    ...     
联系我们
    联系方式
    在线留言
    ...

# 2、分析怎样的数据结构可以方便展示上面的数据?

上面的结构只是做了4层分类,如果业务需求更大,还需要继续进行分类。很显然,通过外键关联查询无法办到,这个时候我们需要对数据进行处理了。

[
    {
      id:1,
      name:"关于我们",
      children:[
          {
            id:2,
            name:"公司介绍",
            children:[
                {
                    id:5,
                    name:"公司简介",
                    children:[]
                },
                {
                    id:6,
                    name:"企业资质",
                    children:[]
                },
                {
                    id:7,
                    name:"企业文化",
                    children:[]
                },
                {
                    id:8,
                    name:"发展历程",
                    children:[]
                },
            ]
          },
          {
            id:3,
            name:"公司动态",
            children:[]
          },
          {
            id:4,
            name:"公司团建",
            children:[]
          },
      ]
   },
   ...
   {
      id:40,
      name:"联系我们",
      children:[
        {
            id:41,
            name:"联系方式",
            children:[]
        },
        {
            id:42,
            name:"在线留言",
            children:[]
        }
      ]
   }
]

//那么这样的结果,可以通过什么数据处理呢?
[
   { id:1 , name:"关于我们" , pid:0 },
   { id:2 , name:"公司介绍" , pid:1 },
   { id:3 , name:"公司动态" , pid:1 },
   { id:4 , name:"公司团建" , pid:1 },
   { id:5 , name:"公司简介" , pid:2 },
   { id:6 , name:"企业资质" , pid:2 },
   { id:7 , name:"企业文化" , pid:2 },
   { id:8 , name:"发展历程" , pid:2 },
   ...
]

# 3、如何将以上结构转成树形结构来方便程序处理和展示?

# 入门级方法

function transformToTree(data) {
  // 创建一个空对象用于存储键值对形式的节点映射
  const nodeMap = {};
  
  // 遍历原始数据,构建节点映射表并初始化节点的 children 属性
  data.forEach(item => {
    const node = { id: item.id, name: item.name, children: [] };
    nodeMap[item.id] = node;
    
    if (item.pid !== 0) {
      // 如果当前节点不是根节点,则将其添加到其父节点的 children 数组中
      if (nodeMap[item.pid]) {
        nodeMap[item.pid].children.push(node);
      }
    }
  });

  // 从映射表中提取所有 pid 为 0 的根节点
  return Object.values(nodeMap).filter(node => node.pid === 0);
}

// 使用给定的数据进行转换
const menuData = [
  { id:1 , name:"关于我们" , pid:0 },
  { id:2 , name:"公司介绍" , pid:1 },
  { id:3 , name:"公司动态" , pid:1 },
  { id:4 , name:"公司团建" , pid:1 },
  { id:5 , name:"公司简介" , pid:2 },
  { id:6 , name:"企业资质" , pid:2 },
  { id:7 , name:"企业文化" , pid:2 },
  { id:8 , name:"发展历程" , pid:2 },
];

const treeData = transformToTree(menuData);

console.log(treeData);

# 树形结构方法演变

由于树形结构数据在很多场景会使用到,如:网站导航栏产品分类权限选择无限极评论及回复等等常景都会用到,所以这里把整个方法的演变过程,单独给大家写了一下,感兴趣的同学可以查阅:
树形结构方法演变

# 演变最终方法:兼容性和扩展性更好的方法

function transformToTree(data, rootPid = 0, level = 0, defaultName = "未命名") {
  const nodeMap = {};

  data.forEach(({ id, pid, enname, ...otherProps }) => {
    let name = enname || defaultName;
    const node = { id, name, pid, level, children: [], ...otherProps };
    nodeMap[id] = node;

    if (pid !== rootPid && nodeMap[pid]) {
      nodeMap[pid].children.push(node);
      node.level = nodeMap[pid].level + 1;
    } else if (pid === rootPid) {
      node.level = level;
    }
  });

  return Object.values(nodeMap).filter(node => node.pid === rootPid);
}

// 示例数据(这种数据更具代表性,也兼容我们上面的数据格式)
const menuData = [
  { id:1 , pid:0 },
  { id:2 , pid:1, enname:"about us" },
  { id:3 , pid:1 },
  { id:4 , pid:1 },
  { id:5 , pid:2 },
  { id:6 , pid:2 },
  { id:7 , pid:2 },
  { id:8 , pid:2 },
];

// 默认情况,rootPid 为 0,层级为 0,默认名称为 "未命名"
const treeData = transformToTree(menuData);

console.log(treeData);

# 测试方法

方法封装到 'app/extend/application.js' 扩展里面

...
//5、将普通带有层级关系数组转成树形结构数组[基本结构须有id,pid字段]
transformToTree(data, rootPid = 0, level = 0, defaultName = "未命名") {
    const nodeMap = {};

    data.forEach(({ id, pid, enname, ...otherProps }) => {
        let name = enname || defaultName;
        const node = { id, name, pid, level, children: [], ...otherProps };
        nodeMap[id] = node;

        if (pid !== rootPid && nodeMap[pid]) {
            nodeMap[pid].children.push(node);
            node.level = nodeMap[pid].level + 1;
        } else if (pid === rootPid) {
            node.level = level;
        }
    });

    return Object.values(nodeMap).filter(node => node.pid === rootPid);
}    

app/controller/admin/live.js 写在index方法里面感受一下

//创建直播功能中的直播间列表页面
async index() {
    ...
    //测试树形结构
    const menuData = [
        { id: 1, name: "关于我们", pid: 0 },
        { id: 2, name: "公司介绍", pid: 1 },
        { id: 3, name: "公司动态", pid: 1 },
        { id: 4, name: "公司团建", pid: 1 },
        { id: 5, name: "公司简介", pid: 2 },
        { id: 6, name: "企业资质", pid: 2 },
        { id: 7, name: "企业文化", pid: 2 },
        { id: 8, name: "发展历程", pid: 2 },
    ];
    // 默认情况,rootPid 为 0,层级为 0,默认名称为 "未命名"
    const treeData = app.transformToTree(menuData);

    console.log('测试树形结构', JSON.stringify(treeData));
}

结果可用 在线json 比对一下:https://www.bejson.com/

# 4、最终表设计

企业网站后台栏目管理(网站导航栏,栏目分类)表 category 字段设计(更多字段根据业务需求来扩展)

表名:category

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

字段含义

id int(20) 主键、自增长、UNSIGNED无符号
pid int(20) 0 上一级(父级)id
name varchar(100) 分类名称
enname varchar(100) 分类英文名称
status int(1) 1 分类状态 0不可用 1可用 等等状态
description varchar(255) 分类描述,分类简单介绍
order int(11) 50 分类排序,默认50
create_time datetime CURRENT_TIMESTAMP 数据创建时间
update_time datetime CURRENT_TIMESTAMP 数据更新时间

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

# 5、创建迁移文件、执行迁移命令创建数据表 category

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

涉及的知识点:

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

创建迁移文件 命令:

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

创建迁移文件:

'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('category', {
      id: {
        type: INTEGER(20).UNSIGNED,
        primaryKey: true,
        autoIncrement: true,
        comment: '分类主键id'
      },
      pid: {
        type: INTEGER(20).UNSIGNED,
        allowNull: false,
        defaultValue: 0,
        comment: '上一级(父级)id',
      },
      name: {
        type: STRING(100),
        allowNull: false,
        defaultValue: '',
        comment: '分类名称'
      },
      enname: {
        type: STRING(100),
        allowNull: true,
        defaultValue: '',
        comment: '分类英文名称'
      },
      status: {
        type: INTEGER(1),
        allowNull: false,
        defaultValue: 1,
        comment: '分类状态 0不可用 1可用 等等状态'
      },
      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('category')
  }
};

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

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

# 6、创建 category 的模型

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

'use strict';

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

    const Category = app.model.define('category', {
        id: {
            type: INTEGER(20).UNSIGNED,
            primaryKey: true,
            autoIncrement: true,
            comment: '分类主键id'
        },
        pid: {
            type: INTEGER(20).UNSIGNED,
            allowNull: false,
            defaultValue: 0,
            comment: '上一级(父级)id',
        },
        name: {
            type: STRING(100),
            allowNull: false,
            defaultValue: '',
            comment: '分类名称'
        },
        enname: {
            type: STRING(100),
            allowNull: true,
            defaultValue: '',
            comment: '分类英文名称'
        },
        status: {
            type: INTEGER(1),
            allowNull: false,
            defaultValue: 1,
            comment: '分类状态 0不可用 1可用 等等状态'
        },
        // 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') }
    });


    return Category;
}

# 7、创建表 category 的控制器、完成后台分类列表功能

创建控制器 app/controller/admin/category.js 完成功能

'use strict';

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

class CategoryController extends Controller {
    //创建分类表表单
    async create() {
        const { ctx } = this;
        //渲染公共模版
        await ctx.renderTemplate({
            title: '创建分类表表单',//现在网页title,面包屑导航title,页面标题
            tempType: 'form', //模板类型:table表格模板 ,form表单模板
            form: {
                //提交地址
                action: "/admin/category/save",
                //  字段
                fields: [
                    {
                        label: '上一级(父级)id',
                        type: 'number',
                        name: 'pid',
                        default: '0',
                        placeholder: '上一级(父级)id',
                    },
                    {
                        label: '分类名称',
                        type: 'text',
                        name: 'name',
                        placeholder: '请输入分类名称',
                        // default:'默认值测试', //新增时候默认值,可选
                    },
                    {
                        label: '分类英文名称',
                        type: 'text',
                        name: 'enname',
                        placeholder: '请输入分类英文名称',
                    },
                    {
                        label: '分类状态',
                        type: 'number',
                        name: 'status',
                        default: '1',
                        placeholder: '分类状态 0不可用 1可用 等等状态',
                    }
                ],
            },
            //新增成功之后跳转到哪个页面
            successUrl: '/admin/category/index',
        });
    }

    //创建分类表表单提交数据
    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可用 等等状态'
            },
            pid: {
                type: 'int',
                required: true,
                defValue: 0,
                desc: '上一级(父级)id'
            }
        });
        //先判断一下直播功能中的礼物账号是否存在,不存在在写入数据库
        //2.写入数据库
        //3.成功之后给页面反馈
        let { name, enname, status, pid} = this.ctx.request.body;
        if (await this.app.model.Category.findOne({ where: { name } })) {
            return this.ctx.apiFail('分类名称已存在');
        }
        //否则不存在则写入数据库
        const res = await this.app.model.Category.create({
            name,
            enname,
            status,
            pid
        });
        this.ctx.apiSuccess('创建分类成功');
    }

    //创建分类列表页面
    async index() {
        const { ctx, app } = this;
        //分页:可以提炼成一个公共方法page(模型名称,where条件,其他参数options)
        let data = await ctx.page('Category');
        //渲染公共模版
        await ctx.renderTemplate({
            title: '分类列表',//现在网页title,面包屑导航title,页面标题
            data,
            tempType: 'table', //模板类型:table表格模板 ,form表单模板
            table: {
                //表格上方按钮,没有不要填buttons
                buttons: [
                    {
                        url: '/admin/category/create',//新增路径
                        desc: '新增分类',//新增 //按钮名称
                        // icon: 'fa fa-plus fa-lg',//按钮图标
                    }
                ],
                //表头
                columns: [
                    {
                        title: '分类名称',
                        key: 'name',
                        class: 'text-center',//可选
                    },
                    {
                        title: '分类英文名称',
                        key: 'enname',
                        class: 'text-center',//可选
                    },
                    {
                        title: '创建时间',
                        key: 'create_time',
                        width: 200,//可选
                        class: 'text-center',//可选
                    },
                    {
                        title: '操作',
                        class: 'text-right',//可选
                        action: {
                            //修改
                            edit: function (id) {
                                return `/admin/category/edit/${id}`;
                            },
                            //删除
                            delete: function (id) {
                                return `/admin/category/delete/${id}`;
                            }
                        }
                    },
                ],
            },
        });
    }
}

module.exports = CategoryController;

# 8、定义路由

// 创建分类界面
router.get('/admin/category/create', controller.admin.category.create);
//创建分类提交数据
router.post('/admin/category/save', controller.admin.category.save);
//创建分类列表页面
router.get('/admin/category/index', controller.admin.category.index);

# 二、后台栏目管理功能升级

# 1、根据下拉框选择新增分类到哪个栏目下

修改控制器 app/controller/admin/category.js

//创建分类表单
    async create() {
        const { ctx,app } = this;

        // 渲染模版前先拿到所有分类
        let data = await ctx.app.model.Category.findAll({
            where: {
                // status: 1
            },
            attributes: ['id', 'name','pid','status'],
        });

        data = JSON.parse(JSON.stringify(data));
        data = data.map(item => {
            return {
                ...item,
                value: item.id,
            }
        });
        console.log('所有分类',data);
        data = app.transformToTree(data);
        data.unshift({id:0,name:'一级分类',pid:0,value:0});
        console.log('处理之后的分类', JSON.stringify(data));

        //渲染公共模版
        await ctx.renderTemplate({
            title: '创建分类表单',//现在网页title,面包屑导航title,页面标题
            tempType: 'form', //模板类型:table表格模板 ,form表单模板
            form: {
                //提交地址
                action: "/admin/category/save",
                //  字段
                fields: [
                    {
                        label: '放在哪个分类里面',
                        type: 'dropdown', //下拉框
                        name: 'pid',
                        default: JSON.stringify(data), //下拉框的数据
                        placeholder: '',
                    },
                    {
                        label: '分类名称',
                        type: 'text',
                        name: 'name',
                        placeholder: '请输入分类名称',
                        // default:'默认值测试', //新增时候默认值,可选
                    },
                    {
                        label: '分类英文名称',
                        type: 'text',
                        name: 'enname',
                        placeholder: '请输入分类英文名称,选填',
                    },
                    {
                        label: '可用状态',
                        type: 'btncheck', //按钮组选择
                        name: 'status',
                        //按钮组选择提供的选择值
                        default: JSON.stringify([ 
                            { value: 1, name: '可用', checked: true },
                            { value: 0, name: '不可用' },
                        ]),
                        placeholder: '可用状态 0不可用 1可用 等等状态',
                    },
                ],
            },
            //新增成功之后跳转到哪个页面
            successUrl: '/admin/category/index',
        });
    }

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

{# 定义一个可递归调用的子模板 #}
{% macro renderMenu(items,keyname) %}
{% for item in items %}
<span class="dropdown-item" style="margin-left: {{item.level * 20}}px;
cursor: pointer;background: none;"
onmouseover="this.style.color = '#000000';this.style.fontWeight = 800;"
onmouseout="this.style.color = '#002222';this.style.fontWeight = 'normal';"
@click="dropdownItemClick('{{keyname}}','{{item.name}}','{{item.id}}')">{{item.name}}</span>

{# 检查是否存在子菜单项 #}
{% if item.children and item.children.length %}
    <div class="dropdown-submenu">
    {% call renderMenu(item.children,keyname) %}
    {# 这里通过调用macro自身实现了递归 #}
    {% endcall %}
    </div>
{% endif %}
{% endfor %}
{% endmacro %}



<div class="card">
    <div class="card-body">
        {% if form %}
        <form action="{{form.action}}" method="post">
            {% for item in form.fields %}
            <div class="form-group row">
                <label class="col-form-label col-md-2">{{item.label}}</label>
                <div class="col-md-10">
                    {# 如果是文件类型 #}
                    {% if item.type == 'file' %}
                    <input class="form-control" type="file" name="{{item.name}}"
                    @change="uploadFile($event,'{{item.name}}')">
                    <img :src="form.{{item.name}}" v-if="form.{{item.name}}" 
                    class="mt-2 p-1 rounded border avatar-lg" >
                    {# 如果是下拉框类型 #}
                    {% elif item.type == 'dropdown' and item.default %}
                    <div class="dropdown">
                        <button type="button" class="btn dropdown-toggle" data-toggle="dropdown" id="dropdownData">
                          请选择
                        </button>
                        <div class="dropdown-menu">
                          <h5 class="dropdown-header">请选择要放在哪个分类下</h5>
                          <div class="dropdown-divider"></div>
                          {# <a class="dropdown-item" href="#">关于我们</a>
                          <a class="dropdown-item" href="#">产品分类</a>
                          <a class="dropdown-item" href="#">新闻中心</a> #}
                          
                          <!-- 在上面的代码块之前或之外的合适位置,提前解析item.default为JSON对象 -->
                          {% set itemDefaultParsed = item.default | safe | fromJson %}
                          {# 使用定义好的子模板递归渲染菜单 #}
                            {% call renderMenu(itemDefaultParsed,item.name) %}
                            {% endcall %}
                        </div>
                    </div>
                    {# 如果是按钮选择 #}
                    {% elif item.type == 'btncheck' %}
                    <div class="btn-group btn-group-{{item.name}}">
                        {# <button type="button" class="btn btn-success">可用</button>
                        <button type="button" class="btn btn-light">不可用</button> #}
                        {% set itemDefaultParsed = item.default | safe | fromJson %}
                        {% for btn in itemDefaultParsed %}
                        <button type="button" class="btn {{'btn-success' if btn.checked else 'btn-light'}}"
                        value="{{btn.value}}" @click="changeBtnStatus('{{item.name}}','btn-group-{{item.name}}','{{btn.value}}','{{ loop.index }}')">{{btn.name}}</button>
                        {% endfor %}
                    </div>
                    {% else %}
                    <input type="{{item.type}}" class="form-control" name="{{item.name}}"  
                    placeholder="{{item.placeholder}}..."
                    v-model="form.{{item.name}}">
                    {% endif %}

                    
                    
                </div>
            </div>    
            {% endfor %}

            ...
        </form> 
        {% endif %}
    </div>
</div> 

<script>
    Vueapp = new Vue({
        el:'#vueapp',
        data(){
           return {
              ...
           }
        },
        methods:{
            submit(){
                console.log('提交成功',this.form);
                console.log('下拉框默认第一项',JSON.parse(this.form.pid.replaceAll('&quot;','"'))[0]);
                console.log('按钮组默认第一项',JSON.parse(this.form.status.replaceAll('&quot;','"'))[0]);
                for(const key in this.form){
                   // const value = this.form[key];
                   // console.log(value);
                   if(this.form[key].indexOf('&quot;')>-1){
                      this.form[key] = JSON.parse(this.form[key].replaceAll('&quot;','"'))[0].value;
                   }
                }
                console.log('提交数据',this.form);
                // return;
                ...
            },
            ...
            //下拉菜单选中项
            dropdownItemClick(keyname,name,id){
                console.log('keyname', keyname);
                console.log('name', name);
                console.log('id', id);
                $('#dropdownData').text(name);
                this.form[keyname] = id;
                console.log('下拉菜单选中项此时的form', JSON.stringify(this.form));
            },
            //按钮组选择
            changeBtnStatus(name,classname,btnValue,index){
              console.log(name,classname,btnValue,index);
              this.form[name] = btnValue;
              console.log('按钮组选择此时的form', JSON.stringify(this.form));
              $('.' + classname).find('button').eq(index-1).addClass('btn-success')
              .siblings().removeClass('btn-success').addClass('btn-light');
            }
        }
    });
</script>


新增过滤器 app/extend/filter.js

module.exports = {
    fromJson(str) {
      try {
        return JSON.parse(str);
      } catch (err) {
        console.error('Failed to parse JSON:', str, err);
        return null; // 或者抛出错误,取决于你的应用逻辑
      }
    },
};

# 2、分类列表树形结构展示

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

'use strict';
const Controller = require('egg').Controller;
class CategoryController extends Controller {
    //分类列表页面
    async index() {
        const { ctx, app } = this;
        //分页:可以提炼成一个公共方法page(模型名称,where条件,其他参数options)
        // let data = await ctx.page('Category');
        let data = await ctx.service.category.datalist({limit:1000});
        console.log('分类列表数据', data);
        data = data.rules;
        // return;
        //渲染公共模版
        await ctx.renderTemplate({
            ...
            table: {
                ...
                //表头
                columns: [
                    {
                        title: '分类名称',
                        key: 'name',
                        class: 'text-left',//可选
                        treeData(item){//树形结构
                            console.log('每个item',item);
                            if(item.level){
                                let w = item.level * 40;
                                return `<span style="display: inline-block;width:${w}px;"></span>`;
                            }
                        }
                    },
                    ...
                ],
            },
        });
    }
}
module.exports = CategoryController;

将数据写入服务service中,新建 app/service/category.js

'use strict';

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

class CategoryService extends Service {
    //分类数据
    async datalist(options) {
        const { ctx, app } = this;
        let data = await ctx.page('Category', {}, {
            order: [
                // ['order', 'desc'],
                ['id', 'desc']
            ],
            attributes: {
                exclude: ['create_time', 'update_time'],//除了这些字段,其他字段都显示
            },
            limit: options && options.limit ? options.limit : 100,
        });
        // 转一下data处理
        let list = JSON.parse(JSON.stringify(data));
        //console.log('list', list);
        let rules = JSON.parse(JSON.stringify(data));
        //console.log('rules', rules);

        // 数据集组合分类树(一维数组) 带level
        let $rule = [];
        function list_to_tree($array, $field = 'pid', $pid = 0, $level = 0) {
            $array.forEach(($value, $index) => {
                // console.log($value);
                if ($value[$field] == $pid) {
                    $value['level'] = $level;
                    $rule.push($value);
                    // unset($array[$key]);
                    // console.log('看一下rule',$rule);
                    // $array.splice($index, 1);
                    list_to_tree($array, $field, $value['id'], $level + 1);
                }
            });
            return $rule;
        }
        //数据集组合分类树(多维数组)
        function list_to_tree2($cate, $field = 'pid', $child = 'child', $pid = 0, $callback = false) {
            // if(!($cate instanceof Array)) return [];
            if (typeof $cate != 'object' || typeof $cate.length != 'number') return [];
            let $arr = [];
            $cate.forEach(($v, $index) => {
                let $extra = true;
                if (typeof $callback == 'function') {
                    $extra = $callback($v);
                }
                if ($v[$field] == $pid && $extra) {
                    $v[$child] = list_to_tree2($cate, $field, $child, $v['id'], $callback);
                    $arr.push($v);
                }
            })
            return $arr;
        }

        return {
            totalCount: data.length,
            rules: list_to_tree(rules),
            // list: list_to_tree2(list),
            list: list_to_tree2(list, 'pid', 'children',0),
        }
    }

}

module.exports = CategoryService;
    

模版调整 app/view/admin/layout/_table.html

...
{% if item2.key %}
{# 如果存在key,说明是渲染数据 #}    
<td class="{{item2.class}}" width="{{item2.width}}">
    {% if item2.treeData %}
        {{item2.treeData(item) | safe }}
    {% endif %}
    <span>{{ item[item2.key] }}</span>
</td>
...

# 3、分类列表中分类状态显示及修改

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

//分类列表页面
async index() {
    ...
    //渲染公共模版
    await ctx.renderTemplate({
        ...
        table: {
            ...
            //表头
            columns: [
                {
                    title: '分类名称',
                    key: 'name',
                    class: 'text-left',//可选
                    render(item){ //树形数据
                        // console.log('每个item',item);
                        if(item.level){
                            let w = item.level * 40;
                            return `<span style="display:inline-block;width:${w}px"></span>`;
                        }
                    }
                },
                ...
                {
                    title: '可用状态',
                    key: 'status',
                    width: 200,//可选
                    class: 'text-center',//可选
                    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" value="${arr[i].value}" data="${item.status}"
                            @click="changeBtnStatus('status','btn-group-${item.id}',${arr[i].value},${i},${item.id})">${arr[i].name}</button>`;
                        }
                        str += `</div>`;
                        return str;
                    },
                    hidekeyData:true,//是否隐藏key对应的数据
                },
                ...
            ],
        },
    });
}
...
//根据id修改分类状态
async updateStatus(){
    const {id,status} = this.ctx.request.body;
    //1.参数验证
    this.ctx.validate({
        status: {
            type: 'int',
            required: true,
            defValue: 1,
            desc: '分类状态 0不可用 1可用 等等状态'
        },
        id: {
            type: 'int',
            required: true,
            // defValue: 0,
            desc: '分类id'
        }
    });
    let data = await this.app.model.Category.findByPk(id);
    if (!data) {
        return this.ctx.apiFail('分类数据存在');
    }
    data.status = status;
    await data.save();
    this.ctx.apiSuccess('修改分类状态成功');
}

路由 app/router/admin/admin.js

//根据id修改分类状态
router.post('/admin/category/updateStatus', controller.admin.category.updateStatus);

模版调整 app/view/admin/layout/_table.html
需要在vue.js钩子函数里面使用jquery,记得在主模版 app/view/admin/layout/main_app.html将jquery.js的引入提前到vue.js引入之前

...
{% if item2.key %}
{# 如果存在key,说明是渲染数据 #}    
<td class="{{item2.class}}" width="{{item2.width}}">
    {% if item2.render %}
    {{ item2.render(item) | safe }}
    {% endif %}
    {% if not item2.hidekeyData %}
    <span>{{ item[item2.key] }}</span>
    {% endif %}
        
</td>
...
<script>
    Vueapp = new Vue({
        el:'#vueapp',
        methods:{
            ...
            //按钮组选中项
            changeBtnStatus(keyname,classname,btnValue,index,id){
                console.log(keyname,classname,btnValue,index,id);
                $.ajax({
                    type: 'POST', 
                    url: "/admin/category/updateStatus?_csrf={{ctx.csrf|safe}}",
                    contentType:'application/json;charset=UTF-8;',
                    data:JSON.stringify({
                        id,
                        status:btnValue
                    }),
                    success: function (response, stutas, xhr) {
                        console.log(response)
                        Vueapp.$refs.toast.show({
                            msg:"修改成功",
                            type:'success',
                            delay:1000,
                            success:function(){
                                // 跳转到某个页面
                                window.location.href = "/admin/category/index";
                            }
                        });
                    },
                    error:function(e){
                        console.log(e)
                        Vueapp.$refs.toast.show({
                            msg:e.responseJSON.data,
                            type:'danger',
                            delay:3000
                        });
                    }
                });
            },
        },
        mounted(){
            $('.btn-group').each(function(index,ele){
                $(ele).find('button').each(function(index,element){
                    // console.log('每一个button的data', $(element).attr('data'));
                    // console.log('每一个button的value', $(element).attr('value'));
                    if($(element).attr('data') == $(element).attr('value')){
                        $(element).removeClass('btn-light').addClass('btn-success');
                    }
                })
            });
        }
    });
</script>

# 4、对分类表补充两个字段:排序字段order、分类描述字段description

先对迁移文件、模型文件、数据库表针对分类表新增一个排序字段:order,并对新增分类增加排序字段
数据库表手动增加排序字段order分类描述字段description
迁移文件:database/migrations/...category.js 模型文件:app/model/category.js

...
description: {
    type: STRING, //不限定长度.默认varchar(255)
    allowNull: true,
    defaultValue: '',
    comment: '分类描述'
},
order: {
    type: INTEGER, //不限定长度.默认int(11)
    allowNull: true,
    defaultValue: 50,
    comment: '分类排序,默认50'
},
...

新增分类添加排序和描述,控制器 app/controller/admin/category.js

//创建分类表单
    async create() {
        const { ctx, app } = this;

        // 渲染模版前先拿到所有分类
        let data = await ctx.app.model.Category.findAll({
            where: {
                // status: 1
            },
            attributes: ['id', 'name', 'pid', 'status'],
        });
        data = JSON.parse(JSON.stringify(data));
        data.unshift({ id: 0, name: '一级分类', pid: 0, status: 1, value: 0 });
        data = data.map(item => {
            return {
                ...item,
                value: item.id
            }
        });
        console.log(data);
        data = app.transformToTree(data);
        console.log('处理之后的分类', JSON.stringify(data));

        //渲染公共模版
        await ctx.renderTemplate({
            title: '创建分类表单',//现在网页title,面包屑导航title,页面标题
            tempType: 'form', //模板类型:table表格模板 ,form表单模板
            form: {
                //提交地址
                action: "/admin/category/save",
                //  字段
                fields: [
                    {
                        label: '放在哪个分类里面',
                        type: 'dropdown', //下拉框
                        name: 'pid',
                        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: 'number',
                        name: 'order',
                        placeholder: '分类排序,默认50',
                        default:50
                    },
                    {
                        label: '可用状态',
                        type: 'btncheck', //按钮组选择
                        name: 'status',
                        default: JSON.stringify([
                            { value: 1, name: '可用', checked: true },
                            { value: 0, name: '不可用' },
                        ]),
                        placeholder: '分类状态 0不可用 1可用 等等状态',
                    },
                ],
            },
            //新增成功之后跳转到哪个页面
            successUrl: '/admin/category/index',
        });
    }

    //分类表单提交数据
    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可用 等等状态'
            },
            pid: {
                type: 'int',
                required: true,
                defValue: 0,
                desc: '上一级(父级)id'
            },
            description: {
                type: 'string',
                required: false,
                defValue: '',
                desc: '分类描述,分类简单介绍'
            },
            order: {
                type: 'int',
                required: false,
                defValue: 50,
                desc: '分类排序,默认50'
            }
        });
        //先判断一下直播功能中的礼物账号是否存在,不存在在写入数据库
        //2.写入数据库
        //3.成功之后给页面反馈
        let { name, enname, status, pid,description, order} = this.ctx.request.body;
        if (await this.app.model.Category.findOne({ where: { name } })) {
            return this.ctx.apiFail('分类名称已存在');
        }
        //否则不存在则写入数据库
        const res = await this.app.model.Category.create({
            name,
            enname,
            status,
            pid,
            description, order
        });
        this.ctx.apiSuccess('创建分类成功');
    }

# 5、修改分类

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

//修改分类界面
    async edit() {
        const { ctx, app } = this;
        const id = ctx.params.id;
        let currentdata = await app.model.Category.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();
        console.log('下拉框显示的所有分类', JSON.stringify(data));
        //return;
        //渲染公共模版
        await ctx.renderTemplate({
            id,
            title: '修改分类:' + currentdata.name ,//现在网页title,面包屑导航title,页面标题
            tempType: 'form', //模板类型:table表格模板 ,form表单模板
            form: {
                //修改直播功能中的礼物提交地址
                action: '/admin/category/update/' + id,
                //  字段
                fields: [
                    {
                        label: '放在哪个分类里面',
                        type: 'dropdown', //下拉框
                        name: 'pid',
                        default: JSON.stringify(data),
                        placeholder: '',
                        id:id,//有id说明操作是修改不是新增
                    },
                    {
                        label: '分类名称',
                        type: 'text',
                        name: 'name',
                        placeholder: '请输入分类名称',
                        // default:'默认值测试', //新增时候默认值,可选
                    },
                    {
                        label: '分类英文名称',
                        type: 'text',
                        name: 'enname',
                        placeholder: '请输入分类英文名称',
                    },
                    {
                        label: '分类描述',
                        type: 'text',
                        name: 'description',
                        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/category/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可用 等等状态'
            },
            pid: {
                type: 'int',
                required: true,
                defValue: 0,
                desc: '上一级(父级)id'
            },
            description: {
                type: 'string',
                required: false,
                defValue: '',
                desc: '分类描述,分类简单介绍'
            },
            order: {
                type: 'int',
                required: false,
                defValue: 50,
                desc: '分类排序'
            }
        });

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

服务 app/service/category.js

//下拉框获取所有分类
async dropdown_Categorylist() {
    const { ctx, app } = this;
    // 渲染模版前先拿到所有分类
    let data = await ctx.app.model.Category.findAll({
        where: {
            // status: 1
        },
        attributes: ['id', 'name', 'pid', 'status'],
    });
    data = JSON.parse(JSON.stringify(data));
    data.unshift({ id: 0, name: '一级分类', pid: 0, status: 1, value: 0 });
    data = data.map(item => {
        return {
            ...item,
            value: item.id
        }
    });
    // console.log(data);
    data = app.transformToTree(data);
    // console.log('处理之后的分类', JSON.stringify(data));
    return data;
}

路由 app/router/admin/admin.js

//修改分类界面
router.get('/admin/category/edit/:id', controller.admin.category.edit);
//修改分类数据功能
router.post('/admin/category/update/:id', controller.admin.category.update);

模版 app/view/admin/layout/_form.html

{# 如果是下拉框类型 #}
<div class="dropdown">
    <button type="button" class="btn dropdown-toggle dropdown-{{item.name}}" data-toggle="dropdown" id="dropdownData">
        {{'不调整(如需调整请选择)'  if item.id else '一级分类'}}
    </button>
...

# 6、删除分类

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

//删除分类
async delete() {
    const { ctx, app } = this;
    const id = ctx.params.id;

    let data = await ctx.service.category.categorylist();
    // console.log(app._collectNodeIds(data, Number(id)));
    let ids = app._collectNodeIds(data, Number(id));

    await app.model.Category.destroy({
        where: {
            id:{
                [this.app.Sequelize.Op.in] : ids
            }
        }
    });
    //提示
    ctx.toast('分类删除成功', 'success');
    //跳转
    ctx.redirect('/admin/category/index');
}

服务 app/service/category.js

//下拉框获取所有分类
async dropdown_Categorylist() {
    const { ctx, app } = this;
    let data = await this.categorylist();
    data = app.transformToTree(data);
    // console.log('处理之后的分类', JSON.stringify(data));
    return data;
}

//取所有分类
async categorylist() {
    const { ctx, app } = this;
    // 渲染模版前先拿到所有分类
    let data = await ctx.app.model.Category.findAll({
        where: {
            // status: 1
        },
        attributes: ['id', 'name', 'pid', 'status'],
    });
    data = JSON.parse(JSON.stringify(data));
    data.unshift({ id: 0, name: '一级分类', pid: 0, status: 1, value: 0 });
    data = data.map(item => {
        return {
            ...item,
            value: item.id
        }
    });
    console.log('取所有分类',data);
    return data;
}

扩展方法 app/extend/application.js

//6、根据id和父级pid的关系,获取所有子节点id
collectNodeIds(data, nodeId) { //数据量不大使用
    const result = [];
    const visited = new Set();

    function recursiveCollect(node) {
        if (!visited.has(node.id)) {
            result.push(node.id);
            visited.add(node.id);

            const children = data.filter(child => child.pid === node.id);
            children.forEach(recursiveCollect);
        }
    }

    const targetNode = data.find(node => node.id === nodeId);
    if (targetNode !== undefined) {
        recursiveCollect(targetNode);
    }

    return result;
},
//7、对6的升级,(构建节点索引)会更高效,特别是在处理大量数据时
buildNodeIndex(data) {
    const nodeIndex = new Map();

    for (const node of data) {
        if (!nodeIndex.has(node.pid)) {
            nodeIndex.set(node.pid, []);
        }
        nodeIndex.get(node.pid).push(node);
    }

    return nodeIndex;
},
_collectNodeIds(data, nodeId, nodeIndex = this.buildNodeIndex(data)) {
    // console.log('nodeIndex',nodeIndex);
    const result = [];
    const visited = new Set();

    function recursiveCollect(node) {
        if (!visited.has(node.id)) {
            result.push(node.id);
            visited.add(node.id);

            const children = nodeIndex.get(node.id) || [];
            children.forEach(recursiveCollect);
        }
    }

    const targetNode = data.find(node => node.id === nodeId);
    if (targetNode !== undefined) {
        recursiveCollect(targetNode);
    }

    return result;
},

路由 app/router/admin/admin.js

//删除分类功能
router.get('/admin/category/delete/:id', controller.admin.category.delete);
更新时间: 2024年4月10日星期三下午5点30分