使用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、首页代码
<!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、注册用户页的代码
<!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、表单填写页的代码
<!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、忘记密码找回的代码
<!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、重置密码的代码
<!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预览填写+上传结果的代码
<!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、生成汇总表的代码
<!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样式代码
/* 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
// 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-8const 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.jsonconst 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:30003、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。结合起来,构建了信息资料采集系统的初步功能。