Skip to content

组课神器

作者:江月迟迟
发表于:2024-12-10
字数统计:4021 字
预计阅读14分钟

介绍

在很多教育网站的平台上,课程的章节目录会使用树型组件呈现,为了方便调整菜单,前端工程师会为其赋予拖拽功能。本题需要在已提供的基础项目中,完成可拖拽树型组件的功能。

准备

text
├── effect.gif
├── css
│   └── index.css
├── index.html
└── js
    ├── data.json
    ├── axios.min.js
    └── index.js

其中:

  • index.html 是主页面。
  • js/index.js 是待完善的 js 文件。
  • js/data.json 是存放数据的 json 文件。
  • js/axios.min.js 是 axios 文件。
  • css/index.css 是 css 样式文件。
  • effect.gif 是完成的效果图。

注意:打开环境后发现缺少项目代码,请复制下述命令至命令行进行下载。

shell
cd /home/project
wget -q https://labfile.oss.aliyuncs.com/courses/18213/test8.zip
unzip test8.zip && rm test8.zip

在浏览器中预览 index.html 页面,显示如下所示:

初始效果

目标

请在 js/index.js 文件中补全代码。

最终效果可参考文件夹下面的 gif 图,图片名称为 effect.gif (提示:可以通过 VS Code 或者浏览器预览 gif 图片)。

img

具体需求如下:

  1. 补全 js/index.js 文件中 ajax 函数,功能为根据请求方式 method 不同,拿到树型组件的数据并返回。具体如下:
  • method === "get" 时,判断 localStorage 中是否存在 keydata 的数据,若存在,则从 localStorage 中直接获取后处理为 json 格式并返回;若不存在则从 ./js/data.json(必须使用该路径请求,否则可能会请求不到数据)中使用 ajax 获取并返回。
  • method === "post" 时,将通过参数 data 传递过来的数据转化为 json 格式的字符串,并保存到 localStorage 中,key 命名为 data

最终返回的数据格式如下:

js
[
    {
        "id": 1001,
        "label": "第一章 Vue 初体验",
        "children": [ ... ]
    },
    {
      "id": 1006,
      "label": "第二章 Vue 核心概念",
      "children": [
          {
              "id": 1007,
              "label": "2.1 概念理解",
              "children": [
                  {
                      "id": 1008,
                      "label": "聊一聊虚拟 DOM",
                      "tag":"文档 1"
                  },
                  ...
              ]
          },
          {
              "id": 1012,
              "label": "2.2 Vue 基础入门",
              "children": [
                  {
                      "id": 1013,
                      "label": "Vue 的基本语法",
                      "tag":"实验 6"
                  },
                  ...
              ]
          }
      ]
  }
]
  1. 补全 js/index.js 文件中的 treeMenusRender 函数,使用所传参数 data 生成指定 DOM 结构的模板字符串(完整的模板字符串的 HTML 样例结构可以在 index.html 中查看),并在包含 .tree-node 的元素节点上加上指定属性如下:
属性名属性值描述
data-grade${grade}表示菜单的层级,整数,由 treeMenusRender 函数的 grade 参数值计算获得,章节是 1,小节是 2,实验文档是 3。
data-index${id}表示菜单的唯一 id,使用每层菜单数据的 id 字段值。
  1. 补全 js/index.js 文件中的 treeDataRefresh 函数,功能为:根据参数列表 { dragGrade, dragElementId }, { dropGrade, dropElementId } 重新生成拖拽后的树型组件数据 treeDatatreeData 为全局变量,直接访问并根据参数处理后重新赋值即可)。

方便规则描述,现将 data.json 中的数据扁平化处理,得到的数据顺序如下:

js
[
  { grade: "1", label: "第一章 Vue 初体验", id: "1001" },
  { grade: "2", label: "1.1 Vue 简介", id: "1002" },
  { grade: "3", label: "Vue 的发展历程", id: "1003" },
  { grade: "3", label: "Vue 特点", id: "1004" },
  { grade: "3", label: "一分钟上手 Vue", id: "1005" },
  { grade: "1", label: "第二章 Vue 核心概念", id: "1006" },
  { grade: "2", label: "2.1 概念理解", id: "1007" },
  { grade: "3", label: "聊一聊虚拟 DOM", id: "1008" },
  { grade: "3", label: "感受一下虚拟 DOM", id: "1009" },
  { grade: "3", label: "聊一聊 MVVM 设计模式", id: "1010" },
  { grade: "3", label: "Vue 中的 MVVM 设计模式", id: "1011" }, // 即将被拖拽的元素节点
  { grade: "2", label: "2.2 Vue 基础入门", id: "1012" },
  { grade: "3", label: "Vue 的基本语法", id: "1013" },
  { grade: "3", label: "第一步,创建 Vue 应用实例", id: "1014" },
]

拖拽前后的规则说明如下:

  • 情况一:若拖拽的节点和放置的节点为同级,即 treeDataRefresh 函数参数列表中 dragGrade == dropGrade,则将 id == dragElementId(例如:1011)的节点移动到 id==dropElementId(例如:1008)的节点后,作为其后第一个邻近的兄弟节点。最终生成的数据顺序如下:
js
[
  { grade: "1", label: "第一章 Vue 初体验", id: "1001" },
  { grade: "2", label: "1.1 Vue 简介", id: "1002" },
  { grade: "3", label: "Vue 的发展历程", id: "1003" },
  { grade: "3", label: "Vue 特点", id: "1004" },
  { grade: "3", label: "一分钟上手 Vue", id: "1005" },
  { grade: "1", label: "第二章 Vue 核心概念", id: "1006" },
  { grade: "2", label: "2.1 概念理解", id: "1007" },
  { grade: "3", label: "聊一聊虚拟 DOM", id: "1008" },
  // 在目标元素节点下方插入
  { grade: "3", label: "Vue 中的 MVVM 设计模式", id: "1011" },
  { grade: "3", label: "感受一下虚拟 DOM", id: "1009" },
  { grade: "3", label: "聊一聊 MVVM 设计模式", id: "1010" },
  // 移除被拖拽的元素节点
  { grade: "2", label: "2.2 Vue 基础入门", id: "1012" },
  { grade: "3", label: "Vue 的基本语法", id: "1013" },
  { grade: "3", label: "第一步,创建 Vue 应用实例", id: "1014" }
]
  • 情况二:若拖拽的节点和放置的节点为上下级,即 treeDataRefresh 函数参数列表中 dragGrade - dropGrade == 1,则将 id == dragElementId(例如:1011)的节点移动到 id==dropElementId(例如:1002)的节点下,并作为其第一个子节点。最终生成的数据顺序如下:
js
[
  { grade: "1", label: "第一章 Vue 初体验", id: "1001" },
  { grade: "2", label: "1.1 Vue 简介", id: "1002" },
  // 在目标元素节点下方插入
  { grade: "3", label: "Vue 中的 MVVM 设计模式", id: "1011" },
  { grade: "3", label: "Vue 的发展历程", id: "1003" },
  { grade: "3", label: "Vue 特点", id: "1004" },
  { grade: "3", label: "一分钟上手 Vue", id: "1005" },
  { grade: "1", label: "第二章 Vue 核心概念", id: "1006" },
  { grade: "2", label: "2.1 概念理解", id: "1007" },
  { grade: "3", label: "聊一聊虚拟 DOM", id: "1008" },
  { grade: "3", label: "感受一下虚拟 DOM", id: "1009" },
  { grade: "3", label: "聊一聊 MVVM 设计模式", id: "1010" },
  // 移除被拖拽的元素节点
  { grade: "2", label: "2.2 Vue 基础入门", id: "1012" },
  { grade: "3", label: "Vue 的基本语法", id: "1013" },
  { grade: "3", label: "第一步,创建 Vue 应用实例", id: "1014" }
];

规定

  • 请勿修改 js/index.js 文件外的任何内容。
  • 请严格按照考试步骤操作,切勿修改考试默认提供项目中的文件名称、文件夹路径、class 名、id 名、图片名等,以免造成无法判题通过。

判分标准

  • 完成目标 1,得 5 分。
  • 完成目标 2,得 10 分。
  • 完成目标 3,得 10 分。

总通过次数: 115 | 总提交次数: 311 | 通过率: 37%

难度: 困难 标签: 蓝桥杯, 2023, 省赛, Web 前端, JavaScript, 异步

题解

我重复测试后未果,故参考他人答案

参考自:蓝桥杯题解区:若始

js
/**
 * @description 模拟 ajax 请求,拿到树型组件的数据 treeData
 * @param {string} url 请求地址
 * @param {string} method 请求方式,必填,默认为 get
 * @param {string} data 请求体数据,可选参数
 * @return {Array} 
 * */

async function ajax({ url, method = 'get', data }) {
    let result
    // TODO:根据请求方式 method 不同,拿到树型组件的数据
    // 当method === "get" 时,localStorage 存在数据从 localStorage 中获取,不存在则从 /js/data.json 中获取
    // 当method === "post" 时,将数据保存到localStorage 中,key 命名为 data
    if (method === 'get') {
      const dataList = localStorage.getItem('data')
      result = dataList ? JSON.parse(dataList) : (await axios({ url, method })).data.data
    }
  
    if (method === 'post') {
      // result = (await axios({ url, method, data })).data
      localStorage.setItem('data', JSON.stringify(data))
    }
    return result
  }




/**
 * @description 找到元素节点的父亲元素中类选择器中含有 tree-node 的元素节点
 * @param {Element} node 传入的元素节点
 * @return {Element} 得到的元素节点
*/
const getTreeNode = (node) => {
    let curElement = node;
    while (!curElement.classList.contains("tree-node")) {
        if (curElement.classList.contains("tree")) {
            break;
        }
        curElement = curElement.parentNode;
    }
    return curElement;
}

/**
 * @description 根据 dragElementId, dropElementId 重新生成拖拽完成后的树型组件的数据 treeData
 * @param {number} dragGrade 被拖拽的元素的等级,值为 dragElement data-grade属性对应的值
 * @param {number} dragElementId 被拖拽的元素的id,值为当前数据对应在 treeData 中的id
 * @param {number} dropGrade 放入的目标元素的等级,值为 dropElement data-grade属性对应的值
 * @param {number} dropElementId 放入的目标元素的id,值为当前数据对应在 treeData 中的id
*/
function treeDataRefresh({ dragGrade, dragElementId }, { dropGrade, dropElementId }) {
    if (dragElementId === dropElementId) return
    // TODO:根据 `dragElementId, dropElementId` 重新生成拖拽完成后的树型组件的数据 `treeData`
    let dragStr = JSON.stringify(getDragElement(treeData, dragElementId))
    let dropStr = JSON.stringify(getDragElement(treeData, dropElementId))
    let treeDataStr = JSON.stringify(treeData)
    if (dragGrade === dropGrade) {
      treeDataStr = treeDataStr.replace(dragStr, '')
      treeDataStr = treeDataStr.replace(dropStr, dropStr + ',' + dragStr)
    }
    if (dragGrade - dropGrade == 1) {
      if (dropStr.includes(dragStr)) dropStr = dropStr.replace(dragStr, '')
      const newDragStr = `${dragStr},`
      const newDropStr = dropStr.replace('[', '[' + newDragStr)
      treeDataStr = treeDataStr.replace(dragStr, '')
      treeDataStr = treeDataStr.replace(dropStr, newDropStr)
    }
    // 处理多余字符
    treeDataStr = treeDataStr.replace(',,', ',').replace('[,', '[').replace(',]', ']')
    treeData = JSON.parse(treeDataStr)
  }

function getDragElement(data, id) {
    for (const obj of flatObj(data)) {
      if (obj.id == id) return obj
    }
}

function flatObj(data) {
    return data.reduce((prev, cur) => {
      prev = [...prev, cur]
      if (cur?.children) prev = [...prev, ...flatObj(cur.children)]
      return prev
    }, [])
  }

/**
 * @description 根据 treeData 的数据生成树型组件的模板字符串,在包含 .tree-node 的元素节点需要加上 data-grade=${index}表示菜单的层级 data-index="${id}" 表示菜单的唯一id
 * @param {array} data treeData 数据
 * @param {number} grade 菜单的层级
 * @return 树型组件的模板字符串
 * 
 * */
function treeMenusRender(data, grade = 0) {
    let treeTemplate = ''
    // TODO:根据传入的 treeData 的数据生成树型组件的模板字符串
    grade++
    for (obj of data) {
      treeTemplate +=
        grade === 3
          ? `<div class="tree-node" data-index="${obj.id}" data-grade="${grade}">
              <div class="tree-node-content" style="margin-left: 30px">
                <div class="tree-node-content-left">
                  <img src="./images/dragger.svg" alt="" class="point-svg" />
                  <span class="tree-node-tag">${obj.tag}</span>
                  <span class="tree-node-label">${obj.label}</span>
                </div>
                <div class="tree-node-content-right">
                  <div class="students-count">
                    <span class="number"> 0人完成</span>
                    <span class="line">|</span>
                    <span class="number">0人提交报告</span>
                  </div>
                  <div class="config">
                    <img class="config-svg" src="./images/config.svg" alt="" />
                    <button class="doc-link">编辑文档</button>
                  </div>
                </div>
              </div>`
          : `<div class="tree-node" data-index="${obj.id}" data-grade="${grade}">
              <div class="tree-node-content" style="margin-left: ${grade === 2 && 15}px">
                <div class="tree-node-content-left">
                  <img src="./images/dragger.svg" alt="" class="point-svg" />
                  <span class="tree-node-label">${obj.label}</span>
                  <img class="config-svg" src="./images/config.svg" alt="" />
                </div>
              </div>`
  
      if (obj?.children) treeTemplate += `<div class="tree-node-children">${treeMenusRender(obj.children, grade)}</div>`
      treeTemplate += `</div>`
    }
    return treeTemplate
  }

let treeData;// 树型组件的数据 treeData

// 拖拽到目标元素放下后执行的函数
const dropHandler = (dragElement, dropElement) => {
    let dragElementId = dragElement.dataset.index;
    let dragGrade = dragElement.dataset.grade;
    if (dropElement) {
        let dropElementId = dropElement.dataset.index;
        let dropGrade = dropElement.dataset.grade;

        treeDataRefresh({ dragGrade, dragElementId }, { dropGrade, dropElementId });
        document.querySelector(".tree").innerHTML = treeMenusRender(treeData);
        document.querySelector("#test").innerText = treeData ? JSON.stringify(treeData):"";
        ajax({ url: "./js/data.json", method: "post", data: treeData });
    }
}
// 初始化
ajax({ url: "./js/data.json" }).then(res => {
    treeData = res;
    document.querySelector("#test").innerText = treeData ? JSON.stringify(treeData):"";
    let treeEle = document.querySelector(".tree");
    treeEle.dataset.grade = 0;
    let treeTemplate = treeMenusRender(treeData)
    treeTemplate && (treeEle.innerHTML = treeTemplate);
    const mDrag = new MDrag(".tree-node", dropHandler);
    // 事件委托,按下小图标记录得到被拖拽的元素,该元素 class 包含 .tree-node
    document.querySelector(".tree").addEventListener("mousedown", (e) => {
        e.preventDefault();
        if (e.target.nodeName.toLowerCase() === "img" && e.target.classList.contains("point-svg")) {
            let dragElement = getTreeNode(e.target);
            // MDrag类的drag方法实现拖拽效果
            mDrag.drag(e, dragElement);
        }
    })
})

/** 
 * @description 实现拖拽功能的类,该类的功能为模拟 HTML5 drag 的功能
 *              鼠标按下后,监听 document 的 mousemove 和 mouseup 事件
 *              当开始拖拽一个元素后会在 body 内插入对应的克隆元素,并随着鼠标的移动而移动
 *              鼠标抬起后,移除克隆元素和 mousemove 事件,如果到达目标触发传入的 dropHandler 方法
 */
class MDrag {
    constructor(dropElementSelector, dropHandler) {
        // 目标元素的选择器
        this.dropElementSelector = dropElementSelector;
        // 拖拽到目标元素放下后执行的函数
        this.dropHandler = dropHandler;

        // 保存所有的目标元素
        this.dropBoundingClientRectArr = [];
        // 被拖拽的元素
        this._dragElement = null;
        // 拖拽中移动的元素
        this._dragElementClone = null;
        // 目标元素
        this._dropElement = null;
        // 拖拽移动事件
        this._dragMoveBind = null;
        // 拖拽鼠标抬起事件
        this._dragUpBind = null;

        this.init();
    }
    init() {
        const dropElements = document.querySelectorAll(this.dropElementSelector);
        this.dropBoundingClientRectArr = Array.from(dropElements).map(el => {
            return { boundingClientRect: el.getBoundingClientRect(), el };
        })
    }
    dragMove(e) {
        const { pageX, pageY } = e;
        this._dragElementClone.style.left = `${e.pageX}px`;
        this._dragElementClone.style.top = `${e.pageY}px`;
        this.setMouseOverElementStyle(pageX, pageY);
    }
    dragend(e) {
      // 移动到目标元素后mouseup事件触发,删除 this._dragElementClone 元素和解除mousemove/mouseup事件
      const { pageX, pageY } = e;
      document.removeEventListener("mousemove", this._dragMoveBind);
      document.removeEventListener("mouseup", this._dragUpBind);
      if (
        Array.from(document.body.children).indexOf(this._dragElementClone) != -1
      ) {
        document.body.removeChild(this._dragElementClone);
      }
      this._dropElement = this.getActualDropElement(pageX, pageY);
      this.drop();
    }
    drag(e, dragElement) {
      this._dragElement = dragElement;
      this._dragElementClone = dragElement.cloneNode(true);
      this._dragElementClone.style.position = "absolute";
      this._dragElementClone.style.left = `${e.pageX - 20}px`;
      this._dragElementClone.style.top = `${e.pageY - 20}px`;
      this._dragElementClone.style.opacity = 0.5;
      this._dragElementClone.style.width = "800px";
      document.body.appendChild(this._dragElementClone);
      // 绑定mousemove和mouseup事件
      this._dragMoveBind = this.dragMove.bind(this);
      this._dragUpBind = this.dragend.bind(this);
      document.addEventListener("mousemove", this._dragMoveBind);
      document.addEventListener("mouseup", this._dragUpBind);
      return this;
    }
    getActualDropElement(pageX, pageY) {
        const dropAttributeArr = this.dropBoundingClientRectArr.filter(obj => pageY >= obj.boundingClientRect.top && pageY <= obj.boundingClientRect.top + obj.boundingClientRect.height);
        if (dropAttributeArr.length == 1) {
            return dropAttributeArr[0].el;
        } else if (dropAttributeArr.length > 1) {
            let temp = dropAttributeArr.reduce((prev, next) => {
                if (Math.abs(pageY - prev.boundingClientRect.top) <= Math.abs(pageY - next.boundingClientRect.top)) {
                    return prev
                } else {
                    return next
                }
            })
            return temp.el;
        } else {
            return null;
        }
    }
    setMouseOverElementStyle(pageX, pageY) {
        let mousemoveEle = this.getActualDropElement(pageX, pageY);
        if (mousemoveEle) {
            this.dropBoundingClientRectArr.forEach(obj => {
                obj.el.classList.contains("mouseover-active") && obj.el.classList.remove("mouseover-active");
            });
            mousemoveEle.classList.add("mouseover-active");
        }
    }
    drop() {
        this.dropHandler && this.dropHandler(this._dragElement, this._dropElement);
        this.init();
    }
}