
一、适用场景
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。结合起来,构建了信息资料采集系统的初步功能。