使用linux+javascript+html+mysql+nodejs+npm+express等构建信息资料采集系统

一、适用场景

1、人才信息库、档案管理,构建企业或单位内部人才库。

2、公务员/事业单位招聘,网上报名填写资料、上传证书等。

3、科研项目申报,课题负责人信息、成果附件、审查材料上传。

4、志愿者招募:在线填写报名信息,上传技能证书、健康证明、等级证书等。

5、金融保险行业信息采集,如:理赔事故照片、医疗单据、身份证明、银行卡复印件。

二、运行环境与拓扑图:

(一)运行环境(所需依赖)

1、服务器中运行必须组件所需的依赖

2、本例中服务器版本(CentOS8升级过内核)

3、本例客户端运行浏览器即可,即BS模式(浏览器Browser------服务器Server)

4、本例实现的功能有:

(1)客户端的用户可通过浏览器完成用户的注册,用户名和密码保存于mysql数据库中,数据库通过加密手段,使密码不明文显示。

(2)客户端通过浏览器填写表单,不需要安装任何多余的插件或软件

(3)客户端可通过浏览器上传证件图片等,支持图片类型如:jpg、jpeg、bmp、png、pdf、webp、gif等。

(4)可通过WEB浏览器预览所填写的资料,上传的图片等,预览WEB页资料时,可将证件图片的缩略图放大查看详情。

(5)使用下拉列表选择(如:性别、学历、学位)+手动填写相结合完成表单资料填写。

(6)可上传文件至服务器,为区分每个人上传的资料,上传前自动建立一个以姓名+电话为名的文件夹,每个人上传的资料只在自己独立的文件夹下,不会混淆别人的资料。

(7)对管理端预览输出的WEB页面资料添加了打印功能。

(8)可以对所有已上传数据的人员进行资料汇总,形成汇总表,在汇总表中想查看某人的信息资料时,单击即可以WEB方式进行预览。

(二)拓扑图

三、实现的过程:

(一)拓扑图完成的流程

1、拓扑图中,底层也可先用一台物理台式机安装环境,图中vmware ESXi系统的搭建,此处不赘述,请参考:

(1)虚拟化部署ESXI6.7+intel x710-da4万兆网卡

https://blog.csdn.net/weixin_43075093/article/details/123985235

(2)虚拟化部署ESXI6.7跑多个vm server系统

https://blog.csdn.net/weixin_43075093/article/details/124055072

(3)管理网络与业务网络分离+虚拟网络部署

https://blog.csdn.net/weixin_43075093/article/details/124072923

(4)虚拟化部署备份+精简置备与厚置备+OVF模板部署

https://blog.csdn.net/weixin_43075093/article/details/124104109

2、拓扑图中的OS操作系统,安装过程此处不赘述,请参考:

虚拟化部署ESXI6.7跑多个vm server系统(CentOS操作系统安装)

https://blog.csdn.net/weixin_43075093/article/details/124055072

3、本例的操作重点在APP环境的搭建,以及服务器端(后端)与客户端(前端)运行代码的编写。

(二)本例中的目录结构与说明

1、目录结构

app/

│─ server.js // 后端入口

│─ package.json

│─ db.js // MySQL 连接池

│─ public/

│ ├─preview

│ ├─ index.html //WEB方式预览某一个人填写和上传的资料

│ └─ total.html // WEB方式预览汇总表

│── uploads //运行时自动创建,用于上传资料的文件夹

│ └─ 张三-13110881111

│ │ └─ info.json

│ │ └─ 身份证正面

│ │ └─ 身份证反面

│ │ └─ 职称证书

│ │ └─ 职业资格证书

│ │ └─ 毕业证书

│ │ └─ 荣誉证书1

│ │ └─ 荣誉证书2

│ │ └─ 荣誉证书3

│ ...

│ └─李四-13911228899

│ │ └─ info.json

│ │ ...

│ └─王五-18955554456

│ ...

│------ register.html // 注册 / 修改密码

│------ index.html // 登录

│------ reset.html //密码重置

│------ forgot.html //忘记密码、找回密码

│------ form.html //登录成功后,填写表单的内容,选择要上传的证件资料

└─ .env // 存放敏感配置

2、package.json是Node.js项目的核心配置文件,用于描述项目元数据、管理依赖关系、定义脚本命令及配置项目参数。

3、‌核心功能概述‌

(1)‌项目元数据管理‌。记录项目名称、版本、作者、许可证等基本信息,相当于项目的"身份证"。‌‌

(2)‌依赖管理‌。dependencies:生产环境依赖包。devDependencies:开发环境专用依赖(如构建工具)。支持语义化版本控制(如^1.2.3和~1.2.3的区别)。

(3)‌脚本命令定义‌。通过scripts字段配置自动化任务(如启动服务、构建、测试)。

4、‌扩展功能:

(1)‌模块入口配置‌:指定主文件(main)和浏览器端入口(browser)。

(2)‌私有配置集成‌:可嵌入ESLint、Browserslist等工具的配置。

(3)‌发布管理‌:为npm包提供元数据,便于开源共享。

‌与其他生态对比:类似Java的pom.xml、Python的requirements.txt或Rust的Cargo.toml,实现依赖与项目配置的标准化。‌‌

(三)前端运行代码

1、首页代码

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8"/>
  <title>信息采集系统</title>
  <link rel="stylesheet" href="style.css"/>

  <!-- 背景图与标题样式 -->
  <style>
    html,body{height:100%;margin:0;}
    body{
      background:url('/image/reg.webp') center/cover no-repeat fixed;
      display:flex;flex-direction:column;align-items:center;
      position:relative;          /* 让绝对定位参考 body */
    }
    /* 标题专用样式 */
    .page-title{
    position:absolute;
    top:120px;                  /* 距离视口顶部 120px(可微调) */
    left:45%;                  /* 水平中心50% */
    transform:translateX(-43%);/* 精确居中-50% */
    font-size:55px;
    font-weight:bold;
    color:#000;
    text-shadow:0 0 6px rgba(0,0,0,.6);
    }
  </style>
</head>
<body>

  <!-- 系统标题,用 class 控制样式 -->
  <h1 class="page-title">信息采集系统</h1>

  <div class="box">
    <h2>用户登录</h2>
    <form id="loginForm">
      <input type="text" placeholder="用户名" id="username" required/>
      <input type="password" placeholder="密码" id="password" required/>
      <button type="submit">登录</button>
      <a href="register.html">还没有账号?立即注册</a>
      <a href="forgot.html">忘记密码</a>
    </form>
  </div>

  <script>
    /* 脚本 */
    document.getElementById('loginForm').addEventListener('submit', async e => {
      e.preventDefault();
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          username: document.getElementById('username').value,
          password: document.getElementById('password').value
        })
      }).then(r => r.json());
      if (res.code) return alert(res.msg);
      location.href = res.data.redirect;
    });
  </script>
</body>
</html>

2、注册用户页的代码

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8"/>
  <title>注册</title>
  <link rel="stylesheet" href="style.css"/>

<!-- 背景图 -->
  <style>
    html,body{
      height:100%;
      margin:0;
    }
    body{
      background:url('/image/reg.webp') center/cover no-repeat fixed;
     display:flex;flex-direction:column;align-items:center;
      position:relative;          /* 让绝对定位参考 body */
    }
 /* 标题专用样式 */
    .page-title{
    position:absolute;
    top:120px;                  /* 距离视口顶部 120px(可微调) */
    left:45%;                  /* 水平中心50% */
    transform:translateX(-43%);/* 精确居中-50% */
    font-size:55px;
    font-weight:bold;
    color:#000;
    text-shadow:0 0 6px rgba(0,0,0,.6);
    }
  </style>
</head>

<body>
<!-- 系统标题,用 class 控制样式 -->
  <h1 class="page-title">信息采集系统</h1>

  <div class="box">
    <h2>用户注册</h2>
    <form id="regForm">
      <input type="text" placeholder="用户名" id="username" required/>
      <span id="userTip"></span>
      <input type="email" placeholder="邮箱" id="email" required/>
      <input type="password" placeholder="密码" id="password" pattern="^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).{8,}$" required/>
      <small>需8位以上,含字母、数字、符号</small>
      <button type="submit">注册</button>
      <a href="index.html">已有账号?去登录</a>
    </form>
  </div>
  <script>
    const username = document.getElementById('username');
    username.addEventListener('blur', async () => {
      if (!username.value) return;
      const res = await fetch('/api/check-username?username=' + encodeURIComponent(username.value)).then(r => r.json());
      document.getElementById('userTip').textContent = res.data.exists ? '用户名已存在' : '用户名可用';
    });

    document.getElementById('regForm').addEventListener('submit', async e => {
      e.preventDefault();
      const res = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          username: username.value,
          email: document.getElementById('email').value,
          password: document.getElementById('password').value
        })
      }).then(r => r.json());
      alert(res.code ? res.msg : '注册成功,去登录');
      if (!res.code) location.href = 'index.html';
    });
  </script>
</body>
</html>

3、表单填写页的代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>教师信息收集</title>
<style>
  body{font-family:Arial;background:#f7f7f7;margin:0}
  .box{max-width:800px;margin:40px auto;background:#fff;padding:30px;border-radius:8px}
  h2{text-align:center}
  label{display:block;margin-top:15px;font-weight:bold}
  input,select,textarea{width:100%;padding:8px;margin-top:4px}
  .img-group{display:flex;flex-wrap:wrap;gap:12px;margin-top:8px}
  .img-item{flex:1 1 180px}
  img.preview{max-width:100%;max-height:120px;border:1px solid #ccc;margin-top:4px}
  button{margin-top:25px;padding:10px 20px;background:#007bff;color:#fff;border:none;border-radius:4px;cursor:pointer}
  .error{color:red;font-size:14px}
  #progressBar{width:100%;height:8px;background:#eee;margin:10px 0;border-radius:4px;overflow:hidden;}
  #progressBar div{height:100%;background:#007bff;width:0%;transition:width .2s;}
  #progressText{margin-bottom:10px;font-size:14px;color:#555;}
</style>

<!-- 背景图 -->
  <style>
    html,body{
      height:100%;
      margin:0;
    }
    body{
      background:url('/image/reg.webp') center/cover no-repeat fixed;
    }
  </style>

</head>
<body>
<div class="box">
  <h2>教师信息 & 证件上传</h2>
  <form id="teacherForm" enctype="multipart/form-data">

    <!-- 基本信息 -->
    <label>姓名<input type="text" name="name" required></label>
    <label>性别
      <select name="gender"><option>男</option><option>女</option></select>
    </label>
    <label>出生日期<input type="date" name="birthday" required></label>
    <label>联系电话<input type="tel" name="phone" required></label>
    <label>邮箱<input type="email" name="email" required></label>

    <!-- 新增:学历(必填) -->
    <label>学历(必填)
      <select name="education" required>
        <option value="">请选择</option>
        <option>中职</option>
        <option>技校</option>
        <option>高中</option>
        <option>专科</option>
        <option>本科</option>
        <option>研究生</option>
      </select>
    </label>

    <!-- 新增:学位 -->
    <label>学位
      <select name="degree">
        <option value="">无</option>
        <option>学士</option>
        <option>硕士</option>
        <option>博士</option>
      </select>
    </label>

    <label>职称<input type="text" name="title" placeholder="如 副教授"></label>
    <label>毕业院校<input type="text" name="school"></label>
    <label>专业<input type="text" name="major"></label>
    <label>个人简介<textarea name="bio" rows="3"></textarea></label>

    <!-- 证件上传 -->
    <fieldset>
      <legend>证件图片(每张 ≤ 5MB,最多20张)</legend>
      <div class="img-item">
        <label>身份证正面<input type="file" name="id_front" accept="image/*"></label>
        <img class="preview" id="preview_id_front">
      </div>
      <div class="img-item">
        <label>身份证反面<input type="file" name="id_back" accept="image/*"></label>
        <img class="preview" id="preview_id_back">
      </div>
      <div class="img-item">
        <label>职称证<input type="file" name="title_cert" accept="image/*"></label>
        <img class="preview" id="preview_title_cert">
      </div>
      <div class="img-item">
        <label>职业资格证<input type="file" name="qual_cert" accept="image/*"></label>
        <img class="preview" id="preview_qual_cert">
      </div>
      <div class="img-item">
        <label>毕业证<input type="file" name="grad_cert" accept="image/*"></label>
        <img class="preview" id="preview_grad_cert">
      </div>
        <div class="img-item">
        <label>学位证书(可选)<input type="file" name="degree_cert" accept="image/*"></label>
        <img class="preview" id="preview_degree_cert">
      </div>

      <!-- 荣誉证 1~15 -->
      <div id="honors"></div>
      <button type="button" onclick="addHonor()">+ 添加荣誉证</button>
    </fieldset>

    <div id="progressText" style="display:none;"></div>
    <div id="progressBar" style="display:none;">
      <div id="progressValue"></div>
    </div>

    <button type="submit">提交</button>
    <div class="error" id="err"></div>
  </form>
</div>

<script>
// 原脚本保持不变
function addHonor(){
  const container = document.getElementById('honors');
  if(container.children.length >= 15) return alert('最多15张荣誉证');
  const idx = container.children.length + 1;
  const div = document.createElement('div');
  div.className = 'img-item';
  div.innerHTML = `
    <label>荣誉证${idx}<input type="file" name="honor_${idx}" accept="image/*"></label>
    <img class="preview" id="preview_honor_${idx}">
  `;
  container.appendChild(div);
}
document.addEventListener('change', e=>{
  if(e.target.type==='file'){
    const [file] = e.target.files;
    if(file && file.size > 5*1024*1024){
      document.getElementById('err').textContent='图片超过5MB:'+file.name;
      e.target.value=''; return;
    }
    document.getElementById('err').textContent='';
    const img = document.getElementById('preview_'+e.target.name);
    if(img) img.src = URL.createObjectURL(file);
  }
});
document.getElementById('teacherForm').addEventListener('submit', async e=>{
  e.preventDefault();
  const progressText = document.getElementById('progressText');
  const progressBar  = document.getElementById('progressBar');
  const progressVal  = document.getElementById('progressValue');
  const msg          = document.getElementById('err');
  const form         = new FormData(e.target);
  progressText.style.display = progressBar.style.display = 'block';
  progressVal.style.width = '0%';
  msg.textContent = '';
  const res = await fetch('/submit', {method:'POST', body: form});
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  while (true) {
    const {done, value} = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, {stream: true});
    const lines = buffer.split('\n');
    buffer = lines.pop() || '';
    for (const line of lines) {
      if (line.startsWith('data:')) {
        const data = line.slice(5).trim();
        if (data.includes('%')) {
          const percent = parseInt(data) || 0;
          progressVal.style.width = percent + '%';
          progressText.textContent = `正在上传 ${percent}%`;
        } else if (data.startsWith('done:')) {
          progressText.textContent = '提交成功,准备跳转...';
          progressVal.style.width = '100%';
          setTimeout(()=>location.href=`/preview/${data.slice(5)}`, 1000);
        } else if (data.startsWith('error:')) {
          msg.textContent = data.slice(6);
          progressText.style.display = progressBar.style.display = 'none';
        } else {
          progressText.textContent = data;
        }
      }
    }
  }
});
</script>
</body>
</html>

4、忘记密码找回的代码

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8"/>
  <title>找回密码</title>
  <link rel="stylesheet" href="style.css"/>

<!-- 背景图 -->
  <style>
    html,body{
      height:100%;
      margin:0;
    }
    body{
      background:url('/image/reg.webp') center/cover no-repeat fixed;
    }
  </style>


</head>
<body>
  <div class="box">
    <h2>找回密码</h2>
    <form id="forgotForm">
      <input type="email" placeholder="注册邮箱" required/>
      <button type="submit">发送重置邮件</button>
      <a href="index.html">返回登录</a>
    </form>
  </div>
  <script>
    document.getElementById('forgotForm').addEventListener('submit', async e => {
      e.preventDefault();
      const res = await fetch('/api/forgot', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email: e.target[0].value })
      }).then(r => r.json());
      alert(res.code ? res.msg : '邮件已发送,10分钟内有效');
    });
  </script>
</body>
</html>

5、重置密码的代码

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8"/>
  <title>重置密码</title>
  <link rel="stylesheet" href="style.css"/>
</head>
<body>
  <div class="box">
    <h2>重置密码</h2>
    <form id="resetForm">
      <input type="password" placeholder="新密码" pattern="^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).{8,}$" required/>
      <button type="submit">提交</button>
    </form>
  </div>
  <script>
    const token = new URLSearchParams(location.search).get('token');
    if (!token) { alert('链接无效'); location.href = 'index.html'; }

    document.getElementById('resetForm').addEventListener('submit', async e => {
      e.preventDefault();
      const res = await fetch('/api/reset', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          token,
          password: e.target[0].value
        })
      }).then(r => r.json());
      alert(res.code ? res.msg : '重置成功,去登录');
      if (!res.code) location.href = 'index.html';
    });
  </script>
</body>
</html>

6、WEB预览填写+上传结果的代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>教师资料预览</title>
<style>
  body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;background:#f7f7f7;margin:0;padding:20px;}
  .card{max-width:720px;margin:0 auto;background:#fff;padding:30px 40px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.1);}
  h1{text-align:center;margin-bottom:30px;color:#333;}
  table{width:100%;border-collapse:collapse;margin-bottom:25px;}
  th,td{padding:10px 8px;border-bottom:1px solid #eee;text-align:left;}
  th{width:160px;color:#666;font-weight:600;}
  .gallery{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:25px;}
  .gallery img{width:180px;height:120px;object-fit:cover;border:1px solid #ddd;border-radius:4px;cursor:pointer;transition:transform .2s;}
  .gallery img:hover{transform:scale(1.05);}
  .no-img{color:#999;font-style:italic;}
  .foot{text-align:center;margin-top:30px;}
  .foot button{padding:8px 24px;font-size:15px;border:none;border-radius:4px;background:#007bff;color:#fff;cursor:pointer;}
  .foot button:hover{background:#0069d9;}

  /* 放大 */
  #overlay{
    position:fixed;inset:0;background:rgba(0,0,0,.7);
    display:flex;align-items:center;justify-content:center;
    z-index:999;cursor:pointer;opacity:0;visibility:hidden;
    transition:opacity .3s;
  }
  #overlay img{
    max-width:90vw;max-height:90vh;
    border-radius:4px;
    cursor:default;
    transform:scale(1);
    transition:transform .3s;
  }
  #overlay.show{opacity:1;visibility:visible;}
</style>

<!-- 背景图 -->
  <style>
    html,body{
      height:100%;
      margin:0;
    }
    body{
      background:url('/image/reg.webp') center/cover no-repeat fixed;
    }
  </style>


</head>
<body>
  <div class="card">
    <h1>教师资料预览</h1>

    <!-- 基本信息 -->
    <table id="infoTable">
      <tr><th>姓名</th><td id="name"></td></tr>
      <tr><th>性别</th><td id="gender"></td></tr>
      <tr><th>出生日期</th><td id="birthday"></td></tr>
      <tr><th>电话号码</th><td id="phone"></td></tr>
      <tr><th>邮件地址</th><td id="email"></td></tr>
      <tr><th>职称</th><td id="title"></td></tr>
      <tr><th>毕业院校</th><td id="school"></td></tr>
      <tr><th>专业</th><td id="major"></td></tr>
      <tr><th>学历</th><td id="education"></td></tr>
      <tr><th>学位</th><td id="degree"></td></tr>
      <tr><th>工作简历</th><td id="bio"></td></tr>
    </table>

    <!-- 证件图片 -->
    <h2>证件图片</h2>
    <div id="gallery" class="gallery">
      <p class="no-img">暂无图片</p>
    </div>

<!-- 底部按钮区 -->
    <div class="foot">
      <button onclick="history.back()">返回</button>
      <button onclick="window.print()">打印</button>
    </div>
  </div>

  <!-- 放大层 -->
  <div id="overlay">
    <img id="overlayImg" alt="大图预览">
  </div>

 <!-- 打印样式 -->
  <style media="print">
    /* 打印时隐藏放大遮罩与按钮 */
    #overlay,
    .foot button {
      display: none !important;
    }
    /* 让卡片宽度占满纸张 */
    .card {
      max-width: 100%;
      box-shadow: none;
      border: none;
    }
    /* 调整图片打印尺寸(可选) */
    .gallery img {
      width: 120px;
      height: 80px;
    }
  </style>


  <script>
  (async () => {
    const folder  = decodeURIComponent(location.pathname.split('/').pop());
    const gallery = document.getElementById('gallery');
    const overlay = document.getElementById('overlay');
    const overlayImg = document.getElementById('overlayImg');

    /* 点击小图 → 放大 */
    gallery.addEventListener('click', e => {
      if (e.target.tagName === 'IMG') {
        overlayImg.src = e.target.src;
        overlayImg.style.transform = 'scale(1)';
        overlay.classList.add('show');
      }
    });

    /* 点击遮罩 → 关闭 */
    overlay.addEventListener('click', () => overlay.classList.remove('show'));

    /* 双击放大/还原(最多 300%) */
    overlayImg.addEventListener('dblclick', () => {
      const current = overlayImg.style.transform;
      overlayImg.style.transform = current === 'scale(3)' ? 'scale(1)' : 'scale(3)';
    });

    /* 加载数据 */
    try {
      const infoRes = await fetch(`/uploads/${encodeURIComponent(folder)}/info.json`);
      if (!infoRes.ok) throw new Error('info.json 读取失败');
      const data = await infoRes.json();
      ['name','gender','birthday','phone','email','title','school','major','education','degree','bio']
        .forEach(k => document.getElementById(k).textContent = data[k] || '-');

      const listRes = await fetch(`/api/files?folder=${encodeURIComponent(folder)}`);
      if (!listRes.ok) throw new Error('文件列表读取失败');
      const imgs = await listRes.json();

      gallery.innerHTML = imgs.length
        ? imgs.map(n => `<img src="/uploads/${encodeURIComponent(folder)}/${encodeURIComponent(n)}" alt="${n}" loading="lazy">`).join('')
        : '<p class="no-img">暂无图片</p>';
    } catch (e) {
      console.error(e);
      document.body.innerHTML = '<h2 style="text-align:center;color:red">资料读取失败!</h2>';
    }
  })();
  </script>
</body>
</html>

7、生成汇总表的代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>教师资料汇总</title>
<style>
  body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;background:#f7f7f7;margin:0;padding:20px;}
  h1{text-align:center;margin-bottom:20px;}
  table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1);}
  th,td{padding:8px 6px;text-align:center;font-size:14px;border-bottom:1px solid #eee;}
  th{background:#fafafa;color:#333;}
  tr:hover{background:#f5faff;cursor:pointer;}
  .ok{color:green;font-weight:bold;}
  .no{color:#ccc;}
  .loading{text-align:center;padding:40px;font-size:18px;}
  .error{color:red;text-align:center;padding:40px;font-size:18px;}
</style>
</head>
<body>
  <h1>教师资料汇总</h1>
  <div id="loading" class="loading">正在加载汇总数据...</div>
  <div id="error" class="error" style="display:none;"></div>
  <table id="totalTable" style="display:none;">
    <thead>
      <tr>
        <th>姓名</th><th>性别</th><th>出生日期</th><th>电话</th><th>邮箱</th>
        <th>职称</th><th>毕业院校</th><th>专业</th><th>学历</th><th>学位</th>
     </tr>
    </thead>
    <tbody></tbody>
  </table>

<!-- 底部按钮区 -->
    <div class="foot" style="display:flex;justify-content:center;gap:20px;align-items:center;margin-top:40px;">
      <button style="font-size:12px;padding:8px 16px;" onclick="history.back()">返回</button>
      <button style="font-size:12px;padding:8px 16px;" onclick="window.print()">打印</button>
    </div>

 <!-- 打印样式 -->
  <style media="print">
    /* 打印时隐藏放大遮罩与按钮 */
    #overlay,
    .foot button {
      display: none !important;
    }
    /* 让卡片宽度占满纸张 */
    .card {
      max-width: 100%;
      box-shadow: none;
      border: none;
    }
    /* 调整图片打印尺寸(可选) */
    .gallery img {
      width: 120px;
      height: 80px;
    }
  </style>

<script>
/* 需要检测的证件文件名(不含扩展名)*/
const CERTS = [
  '身份证正面','身份证反面','职业资格证书','职称证书',
  '荣誉证1','荣誉证2','荣誉证3','荣誉证4','荣誉证5'
];

/* 判断文件是否存在(通过 /api/files)*/
async function fetchFileList(folder){
  const res = await fetch(`/api/files?folder=${encodeURIComponent(folder)}`);
 return res.ok ? res.json() : [];
}

/* 生成一行表格 */
function buildRow(info,folder,files){
  const tr = document.createElement('tr');
  // 基本信息
  ['name','gender','birthday','phone','email','title','school','major','education','degree']
    .forEach(key=>{
      const td=document.createElement('td');
      td.textContent = info[key] || '-';
      tr.appendChild(td);
    });


  /* 点击整行跳转预览页 */
  tr.addEventListener('click', () => {
    location.href = `/preview/${encodeURIComponent(folder)}`;
  });

  return tr;
}

/* 主逻辑 */
(async () => {
  try{
    /* 1. 直接走后端接口拿文件夹列表 */
    const folders = await fetch('/api/folders').then(r=>r.json());
    if(!folders.length) throw new Error('暂无数据');

    /* 2. 并行读取每个文件夹 */
    const tbody = document.querySelector('#totalTable tbody');
    await Promise.all(
      folders.map(async f=>{
        try{
          const [info,files] = await Promise.all([
            fetch(`/uploads/${encodeURIComponent(f)}/info.json`).then(r=>r.json()),
            fetchFileList(f)
          ]);
          tbody.appendChild(buildRow(info,f,files));
        }catch(err){
          console.error('读取失败:'+f,err);
        }
      })
    );

    document.getElementById('loading').style.display='none';
    document.getElementById('totalTable').style.display='table';
  }catch(e){
    document.getElementById('loading').style.display='none';
    const err = document.getElementById('error');
    err.textContent = '汇总数据加载失败:'+e.message;
    err.style.display='block';
  }
})();
</script>
</body>
</html>

8、CSS样式代码

css 复制代码
/* style.css */
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: url('https://images.unsplash.com/photo-1518709268805-4e9042af2176?auto=format&fit=crop&w=1350&q=80') center/cover no-repeat;
  font-family: Arial, sans-serif;
}

.box {
  width: 320px;
  padding: 40px;
  background: rgba(255,255,255,0.9);
  border-radius: 8px;
}

.box h2 { text-align: center; margin-bottom: 20px; }

.box input {
  width: 100%;
  padding: 10px;
  margin: 8px 0;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.box button {
  width: 100%;
  padding: 10px;
  border: none;
  background: #007BFF;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
}

.box a {
  display: block;
  text-align: center;
  margin-top: 10px;
  font-size: 0.9em;
  color: #007BFF;
}

(四)后端代码

1、server.js

javascript 复制代码
// server.js
require('dotenv').config();
const express = require('express');
const mysql = require('mysql2/promise');
const bcrypt = require('bcryptjs');
const bodyParser = require('body-parser');
const rateLimit = require('express-rate-limit');
const nodemailer = require('nodemailer');
const crypto = require('crypto');
const multer   = require('multer');
const fs       = require('fs');
const path     = require('path');
const { v4: uuid } = require('uuid');

const app = express();
const port = 3000;

// 创建 uploads 目录
const uploadDir = path.resolve(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);
app.use(bodyParser.json());
app.use(express.static(__dirname));   // 方便直接跑前端

// 把 uploads 暴露给 /uploads 路径
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// 把 public 暴露给根路径(包含 preview/index.html)
app.use(express.static('public'));

// 登录接口限速:每 IP 每 30 分钟 3 次
const loginLimiter = rateLimit({
  windowMs: 30 * 60 * 1000,
  max: 3,
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => res.status(429).json({ msg: '账户已锁定30分钟' })
});
// 发邮件
const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: Number(process.env.SMTP_PORT),
  secure: true,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
});

// 通用响应
const ok = (res, data = {}) => res.json({ code: 0, data });
const fail = (res, msg) => res.json({ code: 1, msg });

// 用户名是否存在
app.get('/api/check-username', async (req, res) => {
  const [rows] = await pool.execute('SELECT 1 FROM users WHERE username=?', [req.query.username]);
  ok(res, { exists: rows.length > 0 });
});

// 引入 TextDecoder内置模块
const { TextDecoder } = require('util');

// 注册
app.post('/api/register', async (req, res) => {
  const { username, email, password } = req.body;
  if (!/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(password)) {
    return fail(res, '密码需8位以上,包含字母、数字和符号');
  }
  const hash = await bcrypt.hash(password, 12);
  try {
    await pool.execute('INSERT INTO users(username, email, pwd_hash) VALUES (?,?,?)', [username, email, hash]);
    ok(res);
  } catch (e) {
    if (e.code === 'ER_DUP_ENTRY') return fail(res, '用户名或邮箱已存在');
    fail(res, '注册失败');
  }
});

// 登录
app.post('/api/login', loginLimiter, async (req, res) => {
  const { username, password } = req.body;
  const [rows] = await pool.execute('SELECT * FROM users WHERE username=?', [username]);
  if (!rows.length) return fail(res, '用户不存在');
  const user = rows[0];

  // 检查锁定
  if (user.lock_until && new Date() < new Date(user.lock_until)) {
    return fail(res, '账户已锁定30分钟');
  }

  const pwdOk = await bcrypt.compare(password, user.pwd_hash);
  if (!pwdOk) {
    await pool.execute('UPDATE users SET failed=failed+1 WHERE username=?', [username]);
    if (user.failed + 1 >= 3) {
      await pool.execute('UPDATE users SET failed=0, lock_until=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE username=?', [username]);
      return fail(res, '连续登录失败3次,账户锁定30分钟');
    }
    return fail(res, '密码错误');
  }

  await pool.execute('UPDATE users SET failed=0, lock_until=NULL WHERE username=?', [username]);
  ok(res, { redirect: '/form.html' });
});

// 找回密码:发送邮件
app.post('/api/forgot', async (req, res) => {
  const { email } = req.body;
  const [rows] = await pool.execute('SELECT username FROM users WHERE email=?', [email]);
  if (!rows.length) return fail(res, '邮箱未注册');
  const token = crypto.randomBytes(32).toString('hex');
  await pool.execute('UPDATE users SET reset_token=?, reset_exp=DATE_ADD(NOW(), INTERVAL 10 MINUTE) WHERE email=?', [token, email]);
  const link = `${process.env.FRONT_BASE}/reset.html?token=${token}`;
  await transporter.sendMail({
    from: `"找回密码" <${process.env.SMTP_USER}>`,
    to: email,
    subject: '重置密码',
    html: `<a href="${link}">点击重置密码</a>`
  });
  ok(res);
});

// 重置密码
app.post('/api/reset', async (req, res) => {
  const { token, password } = req.body;
  if (!/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(password)) {
    return fail(res, '密码不符合强度要求');
  }
  const [rows] = await pool.execute('SELECT id FROM users WHERE reset_token=? AND reset_exp>NOW()', [token]);
  if (!rows.length) return fail(res, '链接无效或已过期');
  const hash = await bcrypt.hash(password, 12);
  await pool.execute('UPDATE users SET pwd_hash=?, reset_token=NULL, reset_exp=NULL WHERE reset_token=?', [hash, token]);
  ok(res);
});

//multer 配置:图片保存到 uploads,保持原文件名(中文不乱码)
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // 先放到根 uploads 目录,稍后在 /submit 里再移动到最终文件夹
    cb(null, uploadDir);
  },
  filename: (req, file, cb) => {
    // 关键:把 multipart 里 Latin-1 编码的文件名还原为 UTF-8
    const originalName = new TextDecoder('utf-8').decode(
      Buffer.from(file.originalname, 'latin1')
    );
    cb(null, originalName);   // 中文文件名正常
  }
});
const upload = multer({ storage });

// SSE 头
function setSSE(res) {
  res.setHeader('Content-Type',  'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection',    'keep-alive');
  res.flushHeaders();
}

// 提交接口
app.post('/submit', upload.any(), async (req, res) => {
   res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  const { name, phone } = req.body;
  if (!name || !phone) {
    res.write(`event:error\ndata:姓名或电话缺失\n\n`);
   res.end();
   return;
  }

  // 创建目标文件夹
  const folderName = `${name}-${phone}`;
  const folderPath = path.join(uploadDir, folderName);
  if (!fs.existsSync(folderPath)) fs.mkdirSync(folderPath, { recursive: true });

  // 写 info.json
  const jsonPath = path.join(folderPath, 'info.json');
  fs.writeFileSync(jsonPath, JSON.stringify(req.body, null, 2));

  // 把 multer 已保存的文件移动到正确目录
  const files = req.files || [];
 const total = files.length;
  files.forEach((file, idx) => {
    const dst = path.join(folderPath, file.filename);
    fs.renameSync(file.path, dst);
    const percent = Math.round(((idx + 1) / total) * 100);
    res.write(`event:progress\ndata:正在上传第 ${idx + 1}/${files.length} 张...\n\n`);
    res.write(`data:${percent}%\n\n`);
  });
  // 结束
 // res.write(`event:done\ndata:${folderName}\n\n`);
     res.write(`event:done\ndata:资料提交成功,请尽快找管理员审核!\n\n`);
     res.end();
});

// 文件列表接口
app.get('/api/files', (req, res) => {
  const folder = decodeURIComponent(req.query.folder || '');
  const dir    = path.join(__dirname, 'uploads', folder);
  if (!require('fs').existsSync(dir)) return res.status(404).json([]);
  const files = require('fs').readdirSync(dir)
    .filter(f => /.(jpe?g|png|gif|webp|bmp|pdf)$/i.test(f))
    .slice(0, 30);
  res.json(files);
});

// 预览路由:/preview/张三-13555555555 返回 preview/index.html
app.get('/preview/:folder', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'preview', 'index.html'));
});

// 预览页
app.get('/preview/:folder', (req, res) => {
  res.sendFile(path.join(__dirname, 'preview.html'));
});

//列出所有文件夹 的接口
app.get('/api/folders', (req, res) => {
  if (!fs.existsSync(uploadDir)) return res.json([]);
  const dirs = fs.readdirSync(uploadDir, { withFileTypes: true })
    .filter(d => d.isDirectory() && /^.+-\d+$/.test(d.name))
    .map(d => d.name);
  res.json(dirs);
});

app.listen(3000, () => console.log('Server run at http://localhost:3000'));

2、.env
DB_HOST=192.168.0.138
DB_USER=root
DB_PASS=g12345678!23
DB_NAME=user_system
SMTP_HOST=smtp.163.com
SMTP_PORT=465
SMTP_USER=你的发信邮箱
SMTP_PASS=你的发信密码或授权码
FRONT_BASE=http://localhost:3000

3、db连接池
// 数据库连接池
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
  port: process.env.DB_PORT,
  waitForConnections: true,
  connectionLimit: 10
});

四、服务器环境准备

(一)搭建数据库环境(MySQL安装与配置)

1、登录到CentOS,清除缓存

yum clean all

yum makecache

2、下载mysql安装包

wget http://repo.mysql.com/mysql80-community-release-el7-3.noarch.rpm

3、升级包准备安装

rpm -ivh mysql80-community-release-el7-3.noarch.rpm

4、安装mysql

yum install mysql-server -y

5、启动mysql

Service mysqld start

6、查看mysql运行状态

service mysqld status

7、让mysql开机自动启动

systemctl enable mysqld.service

8、登录到mysql(刚安装好时,默认密码为空)

mysql -u root -p

9、修改mysql数据库密码 (刚安装好时,默认密码为空)

mysqladmin -u root -p password

10、系统防火墙开启mysql的3306端口号

firewall-cmd --permanent --add-port=3306/tcp

firewall-cmd --add-port=3306/tcp

firewall-cmd --list-ports

11、打开mysql数据库,修改mysql允许为网络连接,默认仅允许服务器本地连接

Show databases;

Use mysql;

12、查看数据库中有哪些表

Show tables;

select host from user where user = "root";

13、为确保局域网能访问到数据库,注册时能向数据库中写入注册用户信息,配置数据库访问时允许服务器外的访问,否则只允许服务器本地访问

update user set host="%" where user="root";

select host from user where user="root";

14、局域网端电脑安装并使用Navicat Premium 16连接mysql数据库,填写mysql安装时的相关信息,服务器地址,端口号3306,用户root,密码等

15、局域网端电脑连接数据库测试,successful即为成功,如下图:

16、在Navicat Premium 16工具连接上mysql数据库后,打开系统中的数据库,双击tables表,即可看到默认的系统表内容

17、配置新建数据库的访问权限

GRANT ALL PRIVILEGES ON . TO 'username'@'localhost';

GRANT ALL PRIVILEGES ON . TO 'root'@'%';

18、新建本例中所需的数据库和表

CREATE DATABASE IF NOT EXISTS user_system;

USE user_system;

CREATE TABLE IF NOT EXISTS users (

id INT AUTO_INCREMENT PRIMARY KEY,

username VARCHAR(50) NOT NULL UNIQUE,

email VARCHAR(100) NOT NULL UNIQUE,

pwd_hash CHAR(60) NOT NULL,

failed TINYINT DEFAULT 0,

lock_until DATETIME DEFAULT NULL,

reset_token VARCHAR(64) DEFAULT NULL,

reset_exp DATETIME DEFAULT NULL

);

ALTER TABLE users

ADD COLUMN real_name VARCHAR(50) NULL AFTER email,

ADD COLUMN phone VARCHAR(20) NULL AFTER real_name,

ADD COLUMN id_card VARCHAR(18) NULL AFTER phone;

19、查看建好的数据库和表,如下图:

(二)服务器端(后端)核心实现

1、安装(Node.js)运行环境,初始化npm

npm init -y

2、安装npm运行所需的依赖

npm i express mysql2 dotenv bcrypt jsonwebtoken express-rate-limit uuid nodemailer

(1)安装依赖执行命令如下图:

(2)安装npm依赖完成如下图:

3、安装node.js

Node.js下载地址:https://nodejs.org/en/blog/release/v22.18.0

tar -xvf node-v22.18.0-linux-x64.tar.xz

4、将Node.js添加到PATH

为了能够在任何地方运行Node.js,你需要将其添加到你的PATH环境变量中。

echo 'export PATH=$PATH:/opt /node-v22.18.0-linux-x64/bin' >> ~/.bashrc

5、执行node.js环境变量更新

source ~/.bashrc

6、验证node.js的安装

安装完成后,你可以通过运行以下命令来验证Node.js是否正确安装:

node -v

Npm -v

7、npm初始化

Npm init -y

8、npm或手动配置初始化

npm init

:ml-citation{ref="1,3" data="citationList"}

npm -v

9、安装npm的依赖express

npm install express

安装过程如下图:

10、安装npm的依赖mysql2

npm install mysql2

11、安装npm的依赖bcrypt

Npm install bcrypt

12、安装npm的依赖jsonwebtoken

npm install jsonwebtoken

13、安装npm的依赖uuid

Npm install uuid

14、安装npm的依赖nodemialer

npm install nodemailer

五、客户端访问

1、服务器开启端口号3000与3306

firewall-cmd --add-port=3000/tcp

firewall-cmd --add-port=3306/tcp

firewall-cmd --add-port=3000/tcp --permanent

firewall-cmd --add-port=3306/tcp --permanent

2、服务器启动js

Node server.js

3、客户端通过浏览器访问 http://192.168.0.5:3000开始测试。

4、注册用户

5、登录后填写表单

6、登录后上传证件图片相关信息

7、填写好资料后提交

六、后台数据

1、注册1个用户后数据库中的表增加了1条用户记录

2、上传的证件图片每个人区分开,以免混淆

3、填写的表单资料存放于个人文件夹info.json中,图片资料则存放于个人文件夹(姓名-电话)为名称的文件夹

4、管理人员可以查看汇总的数据

5、在汇总的数据列表中,单击某一个人的链接,即可查看此人的详细信息

6、单击图片的缩略图,可放大查看证书的详细信息,如查看职业资格证书

七、生产级加固建议(可选但强烈建议)

1、HTTPS:使用 Nginx + Let's Encrypt。

2、Helmet:防常见 HTTP 头攻击。

3、rate-limit:已用 express-rate-limit 限制暴力破解。

4、CSRF:前后端分离时可加 csurf。

5、日志:接入 winston + morgan。

6、前端部署到 CDN,后端仅暴露 API。

7、前端防篡改,登录用户与填写数据保持一致。

8、用户分角色分配不同的权限,目前是完成的基础功能。

本文至此结束,不足之处敬请批评指正。本例服务器端采用的CentOS8.5,服务器后端运行的代码采用了node.js环境+npm管理工具+express,通过安装npm的各种依赖,后端运行了mysql数据库,通过db池进行连接。前端 采用了javascript+html。结合起来,构建了信息资料采集系统的初步功能。