组件化的可编辑数据表格

1,809 阅读15分钟

组件化的可编辑数据表格

引言

页面中的数据表格通常承载了用户所需的数据,通常用户需要根据实际 情况对部分数据进行修改,本文旨在介绍一种基于组件化思想的可编辑数据表格的设计与实现。在该组件中,用户可以通过点击表格中的可编辑单元格进行修改,并且可以通过JSON动态配置可编辑列、验证规则、样式等可配置项。本项目基于原生JS+HTML+CSS实现,没有额外技术栈,主要锻炼原生JS业务代码编写能力。

初步实现

由于功能可拆分为核心和非核心部分,所以整体设计上采取迭代的方式,首先完成核心功能,即如下功能:

  1. 当点击表格中可编辑数据时,在当前单元格中显示文本框并在其中显示原数据,从而为用户提供修改该数据的输入域
  2. 在编辑模式下点击文本框之外的任何区域即可确认输入
  3. 在编辑模式下输入数据不符合要求时,应该给出适当的提示,并强制改正后方可确认输入

初步表格实现的原型展示:可编辑表格及说明 (axshare.com)

首先我们将数据写死在HTML中,用于验证核心功能:

<table>
        <thead>
          <tr>
            <th class="studentId">学号</th>
            <th>姓名</th>
            <th class="bj">班级</th>
            <th class="chineseGrade">语文成绩</th>
            <th class="mathGrade">数学成绩</th>
            <th class="englishGrade">英语成绩</th>
            <th class="Grade">总成绩</th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td>001</td>
            <td>陈思颖</td>
            <td>一班</td>
            <td>85</td>
            <td>92</td>
            <td>88</td>
          </tr>
          <tr>
            <td>002</td>
            <td>赵果</td>
            <td>二班</td>
            <td>78</td>
            <td>89</td>
            <td>90</td>
          </tr>
          <tr>
            <td>003</td>
            <td>王婷</td>
            <td>三班</td>
            <td>92</td>
            <td>86</td>
            <td>85</td>
          </tr>
          <tr>
            <td>004</td>
            <td>李磊</td>
            <td>一班</td>
            <td>80</td>
            <td>87</td>
            <td>79</td>
          </tr>
          <tr>
            <td>005</td>
            <td>张丹</td>
            <td>二班</td>
            <td>89</td>
            <td>90</td>
            <td>92</td>
          </tr>
          <tr>
            <td>006</td>
            <td>刘谦</td>
            <td>三班</td>
            <td>86</td>
            <td>87</td>
            <td>91</td>
          </tr>
          <tr>
            <td>007</td>
            <td>黄晓鹏</td>
            <td>一班</td>
            <td>75</td>
            <td>82</td>
            <td>78</td>
          </tr>
          <tr>
            <td>008</td>
            <td>郭林</td>
            <td>二班</td>
            <td>91</td>
            <td>94</td>
            <td>93</td>
          </tr>
          <tr>
            <td>009</td>
            <td>黄文静</td>
            <td>三班</td>
            <td>87</td>
            <td>85</td>
            <td>82</td>
          </tr>
          <tr>
            <td>010</td>
            <td>李晓宇</td>
            <td>一班</td>
            <td>84</td>
            <td>88</td>
            <td>77</td>
          </tr>
        </tbody>
      </table>

要提示错误信息,还需要一个弹窗

<div class="error">输入有误哦!请重新输入</div>

在写业务前,将需要用到的DOM元素写在前面做好准备:

//获取成绩和班级元素
let chineseGrade = document.querySelector(".chineseGrade");
let mathGrade = document.querySelector(".mathGrade");
let englishGrade = document.querySelector(".englishGrade");
let bj = document.querySelector(".bj");
let studentId = document.querySelector(".studentId");
let Grade = document.querySelector(".Grade");
//获取需要的表单元素
let table = document.querySelector("table");
let tbody = table.children[1];
let sort = document.querySelector(".sort span");

下面就进入业务逻辑分析:

要实现点击表格数据编辑,需要给每个表格项添加点击事件,在点击时生成一个input输入框,并自动聚焦,且显示表格中当前格的数据,方便编辑,失焦后自动保存,将input的内容放在表格中,删除input元素,实现以上功能的代码如下:

function addClick() {
  var trs = document.querySelectorAll("tbody tr");
  trs.forEach((e) => {
    e.querySelectorAll("td").forEach((x, i) => {
      if (i < 6 && i >= 3) {
        x.onclick = function () {
          let score = x.innerText;
          input = document.createElement("input");
          input.value = score;
          x.innerText = "";
          x.appendChild(input);
          input.focus();
          input.onblur = function () {
            var keys;
            if (input.value < 0 || input.value > 100) {
              document.querySelector(".error").style.display = "block";
              setTimeout(() => {
                document.querySelector(".error").style.display = "none";
              }, 2000);
            } else {
              document.querySelector(".error").style.display = "none";
              data.forEach((num, ind) => {
                if (x.parentElement.children[0].innerText == num["学号"]) {
                  keys = Object.keys(data[ind]);
                  data[ind][
                    keys[Array.from(x.parentElement.children).indexOf(x)]
                  ] = input.value;
                  data[ind]["总成绩"] =
                    Number(data[ind][keys[3]]) +
                    Number(data[ind][keys[4]]) +
                    Number(data[ind][keys[5]]);
                }
              });
              input.remove();
              updataHtml();
            }
          };
          input.onclick = function (e) {
            e.stopPropagation();
          };
        };
      }
    });
  });
}

需要注意的是,由于input是表格元素的子组件,默认点击事件会产生冒泡触发父元素的点击事件(即产生输入框并聚焦),所以需要用stopPropagation()阻止冒泡产生。

其中包含数据验证,在数据错误时,弹出提示框:

 if (input.value < 0 || input.value > 100) {
     document.querySelector(".error").style.display = "block";
    setTimeout(() => {              document.querySelector(".error").style.display = "none";}, 2000);} else {          document.querySelector(".error").style.display = "none";
       }

在正确时,更新html结构,将临时data中的数据响应到DOM上:

function updataHtml() {
  tbody.innerHTML = "";
  data.forEach((e) => {
    let tr = document.createElement("tr");
    for (const i in e) {
      let td = document.createElement("td");
      td.innerHTML = e[i];
      tr.appendChild(td);
    }
    tbody.appendChild(tr);
  });
  addClick();
}

至此表格的基础功能完成,需要注意的是,在进行输入数据更新时,最好将数据存入临时变量中,最后再整体跟新数据,这样方便后期功能的迭代。

添枝加叶

完成了核心功能,其中我最想实现的是排序功能,首先应该在html中显示排序信息:

<div class="sort">当前排序状态:<span>学号升序</span></div>

排序的方式有很多,我使用的是sort操作数组排序,然后将数组反应到dom结构中:

//记录点击次数,偶数为升序排列,奇数为降序排列
let clickNum = 0;
​
//点击语文成绩排序
chineseGrade.addEventListener("click", () => {
  clickNum++;
  if (clickNum % 2 === 0) {
    sort.innerText = "语文成绩升序";
    data.sort((a, b) => {
      return a.语文成绩 - b.语文成绩;
    });
  } else {
    sort.innerText = "语文成绩降序";
    data.sort((a, b) => {
      return b.语文成绩 - a.语文成绩;
    });
  }
  updataHtml();
});
​
//点击数学成绩排序
mathGrade.addEventListener("click", () => {
  clickNum++;
  if (clickNum % 2 === 0) {
    sort.innerText = "数学成绩升序";
    data.sort((a, b) => {
      return a.数学成绩 - b.数学成绩;
    });
  } else {
    data.sort((a, b) => {
      sort.innerText = "数学成绩降序";
      return b.数学成绩 - a.数学成绩;
    });
  }
  updataHtml();
});
​
//点击英语成绩排序
englishGrade.addEventListener("click", () => {
  clickNum++;
  if (clickNum % 2 === 0) {
    sort.innerText = "英语成绩升序";
    data.sort((a, b) => {
      return a.英语成绩 - b.英语成绩;
    });
  } else {
    sort.innerText = "英语成绩降序";
    data.sort((a, b) => {
      return b.英语成绩 - a.英语成绩;
    });
  }
  updataHtml();
});
​
//点击学号排序
studentId.addEventListener("click", () => {
  clickNum++;
  if (clickNum % 2 === 0) {
    data.sort((a, b) => {
      sort.innerText = "学号升序";
      return a.学号 - b.学号;
    });
  } else {
    sort.innerText = "学号降序";
    data.sort((a, b) => {
      return b.学号 - a.学号;
    });
  }
  updataHtml();
});
​
//点击总成绩排序
Grade.addEventListener("click", () => {
  clickNum++;
  if (clickNum % 2 === 0) {
    sort.innerText = "总成绩升序";
    data.sort((a, b) => {
      return a.总成绩 - b.总成绩;
    });
  } else {
    sort.innerText = "总成绩降序";
    data.sort((a, b) => {
      return b.总成绩 - a.总成绩;
    });
  }
  updataHtml();
});
//点击班级排序.
bj.addEventListener("click", () => {
  clickNum++;
  if (clickNum % 2 === 0) {
    sort.innerText = "班级升序";
    data.sort((a, b) => {
      return transform(a.班级) - transform(b.班级);
    });
  } else {
    sort.innerText = "班级降序";
    data.sort((a, b) => {
      return transform(b.班级) - transform(a.班级);
    });
  }
  updataHtml();
});
​
//把班级字符串转换为数字进行排序
function transform(a) {
  let anser = 0;
  switch (a[0]) {
    case "一":
      anser = 1;
      break;
    case "二":
      anser = 2;
      break;
    case "三":
      anser = 3;
      break;
    case "四":
      anser = 4;
      break;
    case "五":
      anser = 5;
      break;
    case "六":
      anser = 6;
      break;
    default:
      break;
  }
  return anser;
}

此处每一列的数据都单独写了排序函数,但是后期可以优化合并成一个排序方式,值得注意的是,班级在表格中用汉字的一二三四表示,所以需要自定义字码表,这样才可对汉字的数字大小进行排序,如果用unicode或ASCII码比大小,汉字的一二三四并没有在其中按照大小编码

当然排序功能全凭个人意愿来写,但是添枝加叶的主要需要实现功能如下:

  1. 假设表格中要显示的数据来自服务端,由 JSON 格式表示,格式如下: [{pro1:val1,pro2:val2,pro3:val3,…},{pro1:val1,pro2:val2,pro3:val3, …},{pro1:val1,pro2:val2,pro3:val3,…},…]
  2. 通过以上 JSON 格式的数组(数据自拟,建议数据对象不少于 3 个属性) 生成可编辑表格,并且能灵活配置可编辑的数据列。
  3. 能分别为不同的可编辑列提供验证规则。
  4. 能配置和实现数据行的可删除操作。
  5. 以上2、3、4均需提供默认配置,以简化可编辑表格函数的参数 传递。
  6. 数据修改能直接映射到从 JSON 生成的数据对象(该对象可作为缓存,以 备提交到服务器)中。

我们定义好json文件,用于动态生成表格,格式如下:

{
  "head": [
    {
      "item": "学号",
      "editable": false,
      "sortable": true,
      "pattern": "^\d{3}$"
    },
    {
      "item": "姓名",
      "editable": false,
      "sortable": false,
      "pattern": "^.{2,4}$"
    },
    {
      "item": "班级",
      "editable": false,
      "sortable": true,
      "pattern": "^\d{1,2}班$"
    },
    {
      "item": "语文",
      "editable": true,
      "sortable": true,
      "pattern": "^150$|^(\d|[1-9]\d|1[0-4]\d)(\.5)?$"
    },
    {
      "item": "数学",
      "editable": true,
      "sortable": true,
      "pattern": "^150$|^(\d|[1-9]\d|1[0-4]\d)(\.5)?$"
    },
    {
      "item": "英语",
      "editable": true,
      "sortable": true,
      "pattern": "^([0-9]{1,2}$)|(^[0-9]{1,2}\.5$)|100$"
    },
    { "item": "总成绩", "editable": false, "sortable": true }
  ],
  "students": [
    ["001", "张丽华", "3班", 85, 76, 91],
    ["002", "陈大明", "6班", 92, 85, 79],...
    ]
}

json中head数组为表头数据,用于动态生成表头,其中有四个字段:

  • item表示表头文字内容
  • editable表示列可编辑性
  • sortable表示列可排序性
  • pattern表示列的数据验证规则,为正则表达式

这样一来,每一列的可排序性,可编辑性,验证规则都可以不同,且都可以根据后端传入数据动态改变

students中每个数组的每个字段和head整体的每个对象item一一对应

要进行表格内容的动态生成,那么首先要从json中获取数据,在本项目中使用axios简化获取步骤:

//获取成绩数据,localstorage不存在从axio获取
function loadData() {
  axios.get("./data.json").then((res) => {
    data = res.data;
    if (localStorage.getItem("data")) data = JSON.parse(localStorage.data);
    else {
      data = res.data;
      localStorage.data = JSON.stringify(data);
    }
    sort.innerText = localStorage.sort || sort.innerText;
}

其中sort是存入localstorage中上次的排序状态,以便于在第二次打开时和data数据排序做对应

下面将进行动态规则验证编写,其实只需要将之前的手动判断改为正则表达式的test方法返回true或false判断即可

input.onblur = function () {
            if (
              data.head[i].pattern &&
              !RegExp(data.head[i].pattern).test(input.value)
            ) {
              document.querySelector(".error").style.display = "block";
              setTimeout(() => {
                document.querySelector(".error").style.display = "none";
              }, 2000);
            } else {
              document.querySelector(".error").style.display = "none";
              if (i === 0) {
                data.students[10 * newcurrentpage - (10 - row)][i] =
                  input.value;
              } else {
                data.students[10 * newcurrentpage - (10 - row)][i] =
                  Number(input.value) || input.value;
              }
​
              input.remove();
              updataHtml();
            }
          };

对于可编辑和可排序性,只需在排序函数中加入验证,如果data.head中的对应字段为true则可执行函数,反之则不可:

//记录点击次数,偶数为升序排列,奇数为降序排列
let clickNum = 0;
function addSort() {
  i = 0;
  data.head.forEach((x) => {
    if (x.sortable) {
      document.querySelectorAll("table thead tr th").forEach((e) => {
        if (e.innerText == x.item) {
          e.onclick = (e) => {
            clickNum++;
            if (clickNum % 2 === 0) {
              //降序
              rank(e.target.innerText, clickNum);
              updataHtml();
            } else {
              //升序
              rank(e.target.innerText, clickNum);
              updataHtml();
            }
          };
        }
      });
    }
  });
}

在这次迭代中,我将所有列的排序方法进行了统一,压缩精简了函数:

function rank(e, clickNum) {
  let index = 0;
  data.head.forEach((elm) => {
    if (elm.item === e) {
      index = data.head.indexOf(elm);
      return;
    }
  });
  data.students.sort((a, b) => {
    if (clickNum % 2 === 0) {
      sort.innerHTML = `${e}降序`;
      localStorage.sort = sort.innerHTML = `${e}降序`;
      return parseFloat(b[index]) - parseFloat(a[index]);
    } else {
      sort.innerHTML = `${e}升序`;
      localStorage.sort = sort.innerHTML = `${e}升序`;
      return parseFloat(a[index]) - parseFloat(b[index]);
    }
  });
}

parseFloat函数对数据进行裁剪,这样只要数据中含有数字即可按照此方法排序

最后在这一部分需要做的最后功能就是删除和新增功能:

//删除行
let ok = document.querySelector(".ok");
let error = document.querySelector(".error");
let del = document.querySelector(".delete");
let delBtn = document.querySelector(".del button");
let tag = false;
delBtn.addEventListener("click", () => {
  let value = del.value;
  data.students.forEach((e) => {
    if (value === e[0]) {
      tag = true;
      nowpage = Math.floor((data.students.indexOf(e) + 1) / 10) + 1;
      if ((data.students.indexOf(e) + 1) / 10 === nowpage - 1) {
        nowpage--;
      }
      if (nowpage === totalpage) {
        lastpageList.pop();
      }
      data.students.splice(data.students.indexOf(e), 1);
      btnn(nowpage);
      ok.style.display = "block";
      setTimeout(() => {
        ok.style.display = "none";
      }, 2000);
      updataHtml();
      return 0;
    }
  });
  if (!tag) {
    error.style.display = "block";
    setTimeout(() => {
      error.style.display = "none";
    }, 2000);
  } else {
    tag = false;
  }
});
//增加行
let newdata = document.querySelector(".add");
newdata.onclick = () => {
  let input = document.querySelector(".input_pop form");
  input.innerHTML = "";
  data.head.forEach((e) => {
    if (e.editable == false && e.item != "总成绩") {
      let div = document.createElement("div");
      div.innerText = e.item + ":";
      let put = document.createElement("input");
      put.setAttribute("type", "text");
      div.appendChild(put);
      input.appendChild(div);
    }
  });
  InputModalPop();
};

其中一些函数涉及到分页和用户交互性使用,所以将在下一节中解释

最终完善

在实现主要功能之后,就是对一些功能进行完善,以及交互性、样式的设计,在上一节中,完成了验证规则和数据的动态生成,但是用户并不能通过更改json的方式来配置列属性,还有在进行新增数据时,需要初始配置不可编辑值,所以我进行了一些交互式弹窗的设计,例如,在进行新增时显示这个弹窗:

image-20230511162859528

此弹窗中表单信息由不可编辑列动态生成,代码见上一部分最后

为了达到用户编辑表格列属性,我添加了右键表头事件,效果如下:

image-20230511163303419

其中的内容和data对象中head数据双向绑定,会动态生成,具体实现代码如下:

html如下:

<div class="modal animated" style="display: none">
      <h2>列属性配置</h2>
      <hr />
      <form>
        <div>列名:<input type="text" /></div>
        <div>
          是否可编辑:<label for="editable">是</label
          ><input type="radio" id="editable" vlaue="editable" name="edit" />
          <label for="uneditable">否</label
          ><input type="radio" id="uneditable" vlaue="uneditable" name="edit" />
        </div>
        <div>
          是否可排序:<label for="sortable">是</label
          ><input type="radio" id="sortable" vlaue="sortable" name="sort" />
          <label for="unsortable">否</label
          ><input type="radio" id="unsortable" vlaue="unsortable" name="sort" />
        </div>
        <div>
          正则表达式(为空则不验证):<br /><textarea></textarea>
          <div>
            快捷验证规则:<button
              type="button"
              onclick="document.querySelector('.modal form textarea').value='^50$|^([0-4]\d?)(\.5)?$'"
            >
              0-50</button
            ><button
              type="button"
              onclick="document.querySelector('.modal form textarea').value='^([0-9]{1,2}$)|(^[0-9]{1,2}\.5$)|100$'"
            >
              0-100</button
            ><button
              type="button"
              onclick="document.querySelector('.modal form textarea').value='^150$|^(\d|[1-9]\d|1[0-4]\d)(\.5)?$'"
            >
              0-150
            </button>
          </div>
        </div>
        <hr />
        <button onclick="modalOut(true)" type="button">确定</button>
        <button onclick="modalOut(false)" type="button">取消</button>
      </form>
    </div>
//增加表头配置
function addConfigure() {
  let name = document.querySelector(".modal form > div input");
  let sortable = document.querySelectorAll(
    ".modal form div:nth-child(3) input"
  );
  let editable = document.querySelectorAll(
    ".modal form div:nth-child(2) input"
  );
  let pattern = document.querySelector(".modal form textarea");
  document.querySelectorAll("table thead tr th").forEach((e, i) => {
    e.addEventListener("contextmenu", function (event) {
      popIndex = i;
      event.preventDefault();
      name.value = data.head[i].item;
      if (data.head[i].sortable) sortable[0].checked = true;
      else sortable[1].checked = true;
      if (data.head[i].editable) editable[0].checked = true;
      else editable[1].checked = true;
      pattern.value = data.head[i].pattern || "";
      modalPop();
    });
  });
}
​
//弹出弹窗
function modalPop() {
  document.querySelector(".mask").style.display = "block";
  document.querySelector(".modal").style.display = "block";
  document.querySelector(".modal").classList.remove("bounceOut");
  document.querySelector(".modal").classList.add("bounceIn");
}

最后,由于数据可能很多,为了控制每页数据数量,我添加了分页组件,由于各部分代码耦合性较高,所以就最终展示完整代码,效果如下

image-20230511164328640

实现效果最终如下:

image-20230511164914957

项目完整代码

html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>可编辑表格</title>
    <link rel="stylesheet" href="styles.css" />
    <link rel="stylesheet" href="animate.css" />
    <script src="./axios.js"></script>
  </head>
  <body>
    <div class="table">
      <h1>可编辑表格</h1>
      <div class="sort">当前排序状态:<span>学号升序</span></div>
      <table>
        <thead></thead>
        <tbody></tbody>
      </table>
      <div class="click">
        <input type="button" value="上一张" class="pre" />
        <div class="btns"></div>
        <input type="button" value="下一张" class="next" />
      </div>
      <div class="error">输入有误哦!请重新输入</div>
      <div class="ok">成功删除</div>
    </div>
    <button class="add">新增一行</button>
    <div class="del">
      <input type="text" class="delete" placeholder="请输入要删除的学号" />
      <button>删!</button>
    </div>
    <div class="mask animated" style="display: none"></div>
    <div class="modal animated" style="display: none">
      <h2>列属性配置</h2>
      <hr />
      <form>
        <div>列名:<input type="text" /></div>
        <div>
          是否可编辑:<label for="editable">是</label
          ><input type="radio" id="editable" vlaue="editable" name="edit" />
          <label for="uneditable">否</label
          ><input type="radio" id="uneditable" vlaue="uneditable" name="edit" />
        </div>
        <div>
          是否可排序:<label for="sortable">是</label
          ><input type="radio" id="sortable" vlaue="sortable" name="sort" />
          <label for="unsortable">否</label
          ><input type="radio" id="unsortable" vlaue="unsortable" name="sort" />
        </div>
        <div>
          正则表达式(为空则不验证):<br /><textarea></textarea>
          <div>
            快捷验证规则:<button
              type="button"
              onclick="document.querySelector('.modal form textarea').value='^50$|^([0-4]\d?)(\.5)?$'"
            >
              0-50</button
            ><button
              type="button"
              onclick="document.querySelector('.modal form textarea').value='^([0-9]{1,2}$)|(^[0-9]{1,2}\.5$)|100$'"
            >
              0-100</button
            ><button
              type="button"
              onclick="document.querySelector('.modal form textarea').value='^150$|^(\d|[1-9]\d|1[0-4]\d)(\.5)?$'"
            >
              0-150
            </button>
          </div>
        </div>
        <hr />
        <button onclick="modalOut(true)" type="button">确定</button>
        <button onclick="modalOut(false)" type="button">取消</button>
      </form>
    </div>
    <div class="modal animated input_pop" style="display: none">
      <h2>新增行不可编辑列初始值配置</h2>
      <hr />
      <form></form>
      <hr />
      <button onclick="InputModalOut(true)" type="button">确定</button>
      <button onclick="InputModalOut(false)" type="button">取消</button>
    </div>
    <section>
      <h2>表格说明</h2>
      <p>
        1.默认前三个字段不可改,后三个字段可改,总成绩不可改(可动态配置,有悬停效果即可改)
      </p>
      <p>2.点击表头可以按照对应列排序(成绩/班级/学号/总成绩)</p>
      <p>
        3.编辑确认时,验证编辑成绩,默认语文数学0-150,英语0-100,可右键点击表头编辑验证规则
      </p>
      <p>4.编辑以上成绩后,鼠标点击其他区域则表示确认修改</p>
      <p>5.表头添加右键点击事件,可弹出弹窗配置列属性</p>
    </section>
  </body>
  <script src="index.js"></script>
</html>

css:

* {
  margin: 0;
  padding: 0;
  --border: 2px solid rgba(121, 121, 121, 1);
}
body {
  background: url(https://imger.nl/images/2023/04/10/11.jpg);
}
h1 {
  font-family: "Rock Salt", cursive;
  padding: 10px;
  font-style: italic;
  caption-side: bottom;
  color: #1a4303;
  letter-spacing: 10px;
  font-size: 50px;
  text-align: center;
}
tr,
td,
th {
  border: var(--border);
  font-weight: 600;
}
​
th {
  font-weight: 600;
  text-align: center;
  background-color: rgb(215, 228, 184);
}
.table {
  width: 50%;
  position: relative;
  padding: 0 20px;
}
table > thead > tr > th,
table > tbody > tr > td {
  width: 90px;
  height: 45px;
  font-size: 16px;
}
​
table {
  background-color: rgb(233, 238, 216);
  text-align: center;
  border: 2px solid black;
  border-spacing: 0;
  border-collapse: collapse; /*设置表格的边框是否被合并为一个单一的边框*/
  margin: 0 auto;
}
input {
  width: inherit;
  height: inherit;
  background-color: #f3f694;
  border: none;
  text-align: center;
  font-family: inherit;
  font-size: inherit;
  font-weight: inherit;
}
.error,
.ok {
  display: none;
  position: absolute;
  top: 100px;
  left: 50%;
  transform: translate(-50%);
  width: 160px;
  text-align: center;
  padding: 15px 18px;
  background: rgb(168, 232, 136);
  border-radius: 20px;
  font-size: 13px;
  font-weight: 600;
  animation-name: move;
  animation-duration: 2s;
  animation-fill-mode: forwards;
}
​
section {
  width: 640px;
  height: 300px;
  padding: 0 10px;
  background-image: url("https://imger.nl/images/2023/04/10/6ff8efe01cfd3a2bfec456b8e3471e04.jpg");
  opacity: 0.8;
  background-size: 100%;
  border: 2px black solid;
  border-radius: 10px;
  display: inline-block;
  position: absolute;
  top: 250px;
  right: 3%;
}
h2 {
  text-align: center;
}
p {
  margin: 20px auto;
  font-size: 16px;
  font-weight: 600;
}
td,
th {
  user-select: none;
  cursor: pointer;
}
​
@keyframes move {
  0% {
    opacity: 0;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
.sort {
  width: 200px;
  margin: 0 auto;
  margin-bottom: 10px;
  height: 15px;
  line-height: 15px;
  text-align: center;
  padding: 15px 18px;
  background: rgb(215, 228, 184);
  border-radius: 10px;
  font-size: 16px;
  font-weight: 600;
  border: var(--border);
}
.click {
  text-align: center;
}
.click button {
  width: 60px;
  height: 30px;
  margin: 5px;
  border: var(--border);
  border-radius: 5px;
  background-color: rgb(233, 238, 216);
  position: relative;
  top: -2px;
}
.click button:hover {
  background-color: #f3f694;
}
.pre,
.next {
  width: 60px;
  height: 30px;
  margin: 5px;
  border: var(--border);
  border-radius: 5px;
  background-color: rgb(251, 255, 238);
}
.add {
  position: absolute;
  top: 200px;
  left: 62%;
  width: 80px;
  height: 30px;
  background-color: #c2eea4;
  border-radius: 5px;
  border: 2px solid gray;
  cursor: pointer;
  font-weight: 600;
  box-sizing: content-box;
}
.add:hover {
  background-color: #f3f694;
}
.del input {
  background-color: unset;
  width: 200px;
  height: 30px;
}
.del {
  position: absolute;
  top: 200px;
  left: 68%;
  border: 2px solid gray;
  border-radius: 4px;
  background-color: rgb(215, 228, 184);
  width: 265px;
  height: 30px;
  box-sizing: content-box;
}
.del > button {
  width: 60px;
  border-radius: 0 4px 4px 0;
  border: none;
  border-left: 2px solid gray;
  height: 30px;
  background-color: #c2eea4;
  position: relative;
  top: -1px;
  font-weight: 600;
  text-align: center;
}
.del > button:hover {
  background-color: #f3f694;
}
.modal {
  width: 500px;
  min-height: 100px;
  text-align: center;
  position: fixed;
  top: 50%;
  left: 50%;
  background-color: rgb(233, 238, 216);
  border: var(--border);
  margin-left: -250px;
  margin-top: -100px;
  z-index: 9;
  border-radius: 15px;
}
.mask {
  width: 100%;
  height: 100%;
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background-color: rgba(255, 255, 255, 0.305);
  backdrop-filter: blur(2px);
  z-index: 8;
}
.modal form > div {
  margin: 10px 0;
  font-weight: 600;
  color: #1a4303;
}
.modal button {
  width: 60px;
  height: 30px;
  margin: 5px;
  border: var(--border);
  border-radius: 5px;
  background-color: rgb(251, 255, 238);
}
.modal button:hover {
  background-color: #d1edab;
}
.modal input[type="text"],
textarea {
  height: 30px;
  background-color: unset;
  border: var(--border);
  border-radius: 5px;
  line-height: 30px;
}
textarea {
  width: 80%;
  margin-top: 10px;
  font-family: inherit;
  font-size: inherit;
  font-weight: inherit;
}
.btns {
  display: inline-block;
}
​

js:

//获取需要的表单元素
let table = document.querySelector("table");
let thead = table.children[0];
let tbody = table.children[1];
let sort = document.querySelector(".sort span");
let btn = document.querySelector(".btns");
const tableList = [];
const pagesize = 10;
var newpageList = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
var lastpageList = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
var page1;
var newcurrentpage = 1;
var data = [];
var popIndex;
let totalpage = 0;
let nowpage = 0;
window.onload = function () {
  loadData();
};
​
//获取成绩数据,localstorage不存在从axio获取
function loadData() {
  axios.get("./data.json").then((res) => {
    data = res.data;
    if (localStorage.getItem("data")) data = JSON.parse(localStorage.data);
    else {
      data = res.data;
      localStorage.data = JSON.stringify(data);
    }
    sort.innerText = localStorage.sort || sort.innerText;
    let tr = document.createElement("tr");
    data.head.forEach((e) => {
      let th = document.createElement("th");
      th.innerText = e.item;
      tr.appendChild(th);
    });
    thead.appendChild(tr);
    data.students.forEach((e) => {
      let body_tr = document.createElement("tr");
      let sum = 0;
      e.forEach((x) => {
        let td = document.createElement("td");
        td.innerText = x;
        body_tr.appendChild(td);
​
        if (typeof x == "number") sum += x;
      });
      e[e.length] = sum;
      let td = document.createElement("td");
      td.innerText = sum;
      body_tr.appendChild(td);
      tbody.appendChild(body_tr);
    });
    btnn();
    addClick();
    addConfigure();
    List(); //显示第一页
  });
}
​
//按钮函数
let flag = 0;
function btnn(nowpage) {
  flag++;
  //拿取数据
  if (data.students.length <= 10) {
    btn.style = "display:none";
  } else {
    const datalength = data.students.length;
    for (let i = 0; i < datalength; i++) {
      tableList[i] = i;
    }
    const currentPage = 1;
    totalpage = Math.ceil(datalength / pagesize);
    page1 = totalpage;
    pageList = tableList.slice(
      (currentPage - 1) * pagesize,
      currentPage * pagesize
    );
    List();
    //创建按钮
    btn.innerHTML = "";
    for (let i = 1; i <= totalpage; i++) {
      let button = document.createElement("button");
​
      btn.insertBefore(button, btn.querySelector(".next"));
      button.textContent = i;
      addbtn(button);
      if (i == 1 && flag === 1) {
        button.style = "background-color:rgb(215, 228, 184);";
      }
      if (i === nowpage) {
        button.style = "background-color:rgb(215, 228, 184);";
      }
    }
  }
}
​
//给按钮增加点击事件
function addbtn(e) {
  e.onclick = function (e) {
    clear();
    newcurrentpage = Number(e.target.innerText);
    newpageList = tableList.slice(
      (newcurrentpage - 1) * pagesize,
      newcurrentpage * pagesize
    );
    e.target.style = "background-color:rgb(215, 228, 184);";
    List();
  };
}
//更新表头
function updataHead() {
  thead.innerHTML = "";
  let tr = document.createElement("tr");
  data.head.forEach((e) => {
    let th = document.createElement("th");
    th.innerText = e.item;
    tr.appendChild(th);
  });
  thead.appendChild(tr);
}
//清除样式
function clear() {
  let allbutton = document.querySelectorAll("button");
  for (let i = 0; i < allbutton.length; i++) allbutton[i].style = "";
}
//分页函数
function List() {
  tbody.innerHTML = "";
  for (let i = 0; i < newpageList.length; i++) {
    let body_tr = document.createElement("tr");
    tbody.appendChild(body_tr);
    let sum = 0;
    for (let j = 0; j < data.head.length; j++) {
      if (data.students[newpageList[i]]) {
        let td = document.createElement("td");
        td.innerText = data.students[newpageList[i]][j];
        body_tr.appendChild(td);
        if (
          typeof data.students[newpageList[i]][j] == "number" &&
          j != data.head.length - 1
        ) {
          if (j != 0) {
            sum += data.students[newpageList[i]][j];
          }
        }
        if (j == data.head.length - 1) {
          td.innerText = sum;
        }
      } else {
        break;
      }
    }
  }
  addClick();
  addSort();
}
//增加表头配置
function addConfigure() {
  let name = document.querySelector(".modal form > div input");
  let sortable = document.querySelectorAll(
    ".modal form div:nth-child(3) input"
  );
  let editable = document.querySelectorAll(
    ".modal form div:nth-child(2) input"
  );
  let pattern = document.querySelector(".modal form textarea");
  document.querySelectorAll("table thead tr th").forEach((e, i) => {
    e.addEventListener("contextmenu", function (event) {
      popIndex = i;
      event.preventDefault();
      name.value = data.head[i].item;
      if (data.head[i].sortable) sortable[0].checked = true;
      else sortable[1].checked = true;
      if (data.head[i].editable) editable[0].checked = true;
      else editable[1].checked = true;
      pattern.value = data.head[i].pattern || "";
      modalPop();
    });
  });
}
​
//弹出弹窗
function modalPop() {
  document.querySelector(".mask").style.display = "block";
  document.querySelector(".modal").style.display = "block";
  document.querySelector(".modal").classList.remove("bounceOut");
  document.querySelector(".modal").classList.add("bounceIn");
}
function InputModalPop() {
  document.querySelector(".mask").style.display = "block";
  document.querySelector(".input_pop").style.display = "block";
  document.querySelector(".input_pop").classList.remove("bounceOut");
  document.querySelector(".input_pop").classList.add("bounceIn");
}
function InputModalOut(bool) {
  if (bool) {
    let div = document.querySelectorAll(".input_pop form div input");
    let newtr = [];
    let unedit = [];
    data.head.forEach((e, i) => {
      if (!e.editable && e.item != "总成绩") {
        unedit.push(i);
      }
    });
    let j = 0;
    let flag = 1;
    for (let i = 0; i < data.head.length; i++) {
      if (
        j < unedit.length &&
        (!div[j].value.trim() ||
          !RegExp(data.head[unedit[j]].pattern).test(div[j].value))
      ) {
        flag = 0;
      }
​
      if (unedit.includes(i)) newtr[i] = div[j++].value;
      else newtr[i] = "";
    }
    if (flag) {
      data.students[data.students.length] = newtr;
      const datalength = data.students.length;
      for (let i = 0; i < datalength; i++) {
        tableList[i] = i;
      }
      newpageList = tableList.slice(
        Math.floor((data.students.length - 1) / pagesize) * pagesize,
        Math.floor((data.students.length - 1) / pagesize) * pagesize + 10
      );
​
      updataHtml();
      btnn();
      clear();
      document.querySelector(".mask").style.display = "none";
      document.querySelector(".input_pop").classList.remove("bounceIn");
      document.querySelector(".input_pop").classList.add("bounceOut");
      setTimeout(() => {
        document.querySelector(".input_pop").style.display = "none";
      }, 700);
      btn.querySelector(":last-child").style =
        "background-color:rgb(215, 228, 184);";
    } else alert("不可编辑列必须有初始值,且符合对应列验证规则!");
  } else {
    document.querySelector(".mask").style.display = "none";
    document.querySelector(".input_pop").classList.remove("bounceIn");
    document.querySelector(".input_pop").classList.add("bounceOut");
    setTimeout(() => {
      document.querySelector(".input_pop").style.display = "none";
    }, 700);
  }
}
function modalOut(bool) {
  document.querySelector(".mask").style.display = "none";
  document.querySelector(".modal").classList.remove("bounceIn");
  document.querySelector(".modal").classList.add("bounceOut");
  setTimeout(() => {
    document.querySelector(".modal").style.display = "none";
  }, 700);
  if (bool) {
    let name = document.querySelector(".modal form > div input");
    let sortable = document.querySelectorAll(
      ".modal form div:nth-child(3) input"
    );
    let editable = document.querySelectorAll(
      ".modal form div:nth-child(2) input"
    );
    let pattern = document.querySelector(".modal form textarea");
    data.head[popIndex].item = name.value;
    data.head[popIndex].pattern = pattern.value;
    if (sortable[0].checked) data.head[popIndex].sortable = true;
    else data.head[popIndex].sortable = false;
    if (editable[0].checked) data.head[popIndex].editable = true;
    else data.head[popIndex].editable = false;
    updataHead();
    updataHtml();
    addSort();
    addConfigure();
  }
}
//上一页函数
let pre = document.querySelector(".pre");
pre.addEventListener("click", (e) => {
  clear();
  console.log(e.target); //输出的当前所在页 例如 2
  newpageList = []; //创建一个空数组
  newcurrentpage--; //当前页减一
  if (newcurrentpage <= 0) {
    newcurrentpage = page1;
  }
  let allbutton = document.querySelectorAll("button");
  console.log(allbutton.values);
  for (let i = 1; i <= allbutton.length; i++) {
    if (i == newcurrentpage) {
      allbutton[i - 1].style = "background-color:rgb(215, 228, 184);";
    }
  }
  newpageList = tableList.slice(
    (newcurrentpage - 1) * pagesize,
    newcurrentpage * pagesize
  ); //传入数组 例如[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 第一页
  e.stopPropagation();
  List(); //调用分页函数
});
​
//下一页函数
let next = document.querySelector(".next");
next.addEventListener("click", (e) => {
  clear();
  console.log(newcurrentpage); //输出的当前所在页 例如 2
  newpageList = []; //创建一个空数组
  newcurrentpage++; //当前页加一
  if (newcurrentpage > page1) {
    newcurrentpage = 1;
  }
  console.log(newcurrentpage); //输出 1
  let allbutton = document.querySelectorAll("button");
  for (let i = 1; i <= allbutton.length; i++) {
    if (i == newcurrentpage) {
      allbutton[i - 1].style = "background-color:rgb(215, 228, 184);";
    }
  }
  newpageList = tableList.slice(
    (newcurrentpage - 1) * pagesize,
    newcurrentpage * pagesize
  ); //传入数组 例如[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 第一页
  e.stopPropagation();
  List(); //调用分页函数
});
​
//记录点击次数,偶数为升序排列,奇数为降序排列
let clickNum = 0;
function addSort() {
  i = 0;
  data.head.forEach((x) => {
    if (x.sortable) {
      document.querySelectorAll("table thead tr th").forEach((e) => {
        if (e.innerText == x.item) {
          e.onclick = (e) => {
            clickNum++;
            if (clickNum % 2 === 0) {
              //降序
              rank(e.target.innerText, clickNum);
              updataHtml();
            } else {
              //升序
              rank(e.target.innerText, clickNum);
              updataHtml();
            }
          };
        }
      });
    }
  });
}
​
//排序
function rank(e, clickNum) {
  let index = 0;
  data.head.forEach((elm) => {
    if (elm.item === e) {
      index = data.head.indexOf(elm);
      return;
    }
  });
  data.students.sort((a, b) => {
    if (clickNum % 2 === 0) {
      sort.innerHTML = `${e}降序`;
      localStorage.sort = sort.innerHTML = `${e}降序`;
      return parseFloat(b[index]) - parseFloat(a[index]);
    } else {
      sort.innerHTML = `${e}升序`;
      localStorage.sort = sort.innerHTML = `${e}升序`;
      return parseFloat(a[index]) - parseFloat(b[index]);
    }
  });
}
​
//更新表格
function updataHtml() {
  let index = 0;
  data.head.forEach((e) => {
    if (e.item === "总成绩") {
      index = data.head.indexOf(e);
    }
  });
  tbody.innerHTML = "";
  data.students.forEach((e) => {
    let body_tr = document.createElement("tr");
    let sum = 0;
    e.forEach((x) => {
      let td = document.createElement("td");
      if (e.indexOf(x) === index) {
        td.innerHTML = sum;
      } else {
        if (typeof x == "number") {
          sum += x;
        }
        td.innerHTML = x;
      }
      body_tr.appendChild(td);
    });
    e[index] = sum;
    tbody.appendChild(body_tr);
  });
  localStorage.data = JSON.stringify(data);
  addSort();
  addClick();
  List();
}
//添加悬停效果
function onHover(x) {
  x.onmouseenter = function () {
    this.style.backgroundColor = "#f3f694";
  };
  x.onmouseleave = function () {
    this.style.backgroundColor = "unset";
  };
}
//添加表格点击事件
function addClick() {
  let index = [];
  data.head.forEach((e, i) => {
    if (e.editable) index.push(i);
  });
  var trs = document.querySelectorAll("tbody tr");
  trs.forEach((e, row) => {
    e.querySelectorAll("td").forEach((x, i) => {
      if (index.includes(i)) {
        onHover(x);
        x.onclick = function () {
          let score = x.innerText;
          input = document.createElement("input");
          input.value = score;
          if (i == 0) {
            input.value = score.toString().padStart(3, "0");
          }
          x.innerText = "";
          x.appendChild(input);
          input.focus();
          input.select();
          input.onblur = function () {
            if (
              data.head[i].pattern &&
              !RegExp(data.head[i].pattern).test(input.value)
            ) {
              document.querySelector(".error").style.display = "block";
              setTimeout(() => {
                document.querySelector(".error").style.display = "none";
              }, 2000);
            } else {
              document.querySelector(".error").style.display = "none";
              if (i === 0) {
                data.students[10 * newcurrentpage - (10 - row)][i] =
                  input.value;
              } else {
                data.students[10 * newcurrentpage - (10 - row)][i] =
                  Number(input.value) || input.value;
              }
​
              input.remove();
              updataHtml();
            }
          };
          input.onclick = function (e) {
            e.stopPropagation();
          };
        };
      }
    });
  });
}
​
//删除行
let ok = document.querySelector(".ok");
let error = document.querySelector(".error");
let del = document.querySelector(".delete");
let delBtn = document.querySelector(".del button");
let tag = false;
delBtn.addEventListener("click", () => {
  let value = del.value;
  data.students.forEach((e) => {
    if (value === e[0]) {
      tag = true;
      nowpage = Math.floor((data.students.indexOf(e) + 1) / 10) + 1;
      if ((data.students.indexOf(e) + 1) / 10 === nowpage - 1) {
        nowpage--;
      }
      if (nowpage === totalpage) {
        lastpageList.pop();
      }
      data.students.splice(data.students.indexOf(e), 1);
      btnn(nowpage);
      ok.style.display = "block";
      setTimeout(() => {
        ok.style.display = "none";
      }, 2000);
      updataHtml();
      return 0;
    }
  });
  if (!tag) {
    error.style.display = "block";
    setTimeout(() => {
      error.style.display = "none";
    }, 2000);
  } else {
    tag = false;
  }
});
//增加行
let newdata = document.querySelector(".add");
newdata.onclick = () => {
  let input = document.querySelector(".input_pop form");
  input.innerHTML = "";
  data.head.forEach((e) => {
    if (e.editable == false && e.item != "总成绩") {
      let div = document.createElement("div");
      div.innerText = e.item + ":";
      let put = document.createElement("input");
      put.setAttribute("type", "text");
      div.appendChild(put);
      input.appendChild(div);
    }
  });
​
  InputModalPop();
};