164
社区成员
发帖
与我相关
我的任务
分享目录
一个基于前后端分离架构的轻量级联系人管理应用。使用原生HTML/CSS/JavaScript构建前端界面,Python Flask框架提供后端API服务,SQLite作为数据存储,实现了完整的联系人信息增删改查功能。项目采用清晰的前后端分离设计,提供简洁直观的用户界面和流畅的操作体验。
GitHub地址:https://github.com/HappyParite/concacts_frontend
GitHub地址:https://github.com/HappyParite/concacts_backend
网站地址:http://localhost:63342/untitled/concacts/forntend/src/concacts.html
| PSP阶段 | 任务内容 | 预估时间(h) | 实际时间(h) |
|---|---|---|---|
| 计划 | 明确作业要求,选择技术栈(HTML+JavaScript+MySQL),规划项目结构 | 0.5 | 0.5 |
| 开发 | 环境准备:安装webstorm,MySQL,JavaScript配置开发环境 | 1.0 | 1.5 |
| 创建项目目录结构,初始化Git仓库 | 0.3 | 0.3 | |
| 后端开发:编写API和MySQL数据库操作 | 2.0 | 2.5 | |
| 前端开发:编写HTML界面和JavaScript交互 | 2.0 | 2.5 | |
| 前后端联调:测试API接口和数据交互 | 1.0 | 1.5 | |
| 测试 | 功能测试:验证添加、修改、删除联系人功能 | 0.5 | 0.7 |
| 兼容性测试:检查不同浏览器下的表现 | 0.3 | 0.4 | |
| 部署 | 项目部署到服务器(可选) | 1.0 | - |
| 文档 | 编写README.md和codestyle.md文档 | 0.5 | 0.6 |
| 撰写博客文章,整理截图和说明 | 1.5 | 2.0 | |
| 总计 | 10.6 | 12.4 |
主页面展示👇




以下是删除联系人功能,点击删除按键删除你的联系人,结果如下

以下是编辑联系人功能,点击编辑按键编辑你的联系人,结果如下


前端:基于原生 HTML + CSS + JavaScript 的轻量级 Web 应用,使用 fetch 调用后端 API,JWT 保存在 localStorage。
后端:基于 Node.js + Express 的 API 服务,提供 /api/auth 与 /api/contacts 等 REST 接口,启用 CORS、鉴权与错误处理中间件。
通信方式:HTTP / JSON(受保护接口携带 Authorization: Bearer <JWT>)。
数据存储:MySQL,核心表:
users(id, username UNIQUE, password_hash, created_at)
contacts(id, name, phone, email, note, user_id, created_at, updated_at)
关键约束:FOREIGN KEY(contacts.user_id → users.id ON DELETE CASCADE);UNIQUE(user_id, phone)
前端选择原生技术:上手快、依赖少、体积小、浏览器原生支持,便于课程演示与后续迁移到框架(Vue/React)。
后端选择 JavaScript:前后端同语种降低心智负担;Express 生态成熟,中间件丰富;事件驱动对 I/O 友好,开发效率高。
数据库选择 MySQL:关系模型贴合“用户-联系人”一对多;事务与约束完善;工具链成熟;utf8mb4 兼容中英文与 emoji。
CREATE DATABASE IF NOT EXISTS contacts_db
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE contacts_db;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS contacts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
phone VARCHAR(32) NOT NULL,
email VARCHAR(120),
note VARCHAR(255),
user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_contacts_user (user_id),
UNIQUE KEY uk_user_phone (user_id, phone)
);
import "dotenv/config";
import dotenv from "dotenv";
import express from "express";
import cors from "cors";
dotenv.config();
import authRouter from "./backend/src/routes/auth.js";
import contactsRouter from "./backend/src/routes/contacts.js";
import { authRequired } from "./backend/src/middleware/auth.js";
const app = express();
app.use(cors());
app.use(express.json());
// 健康检查(前端“关于系统”会调用)
app.get("/api/health", (_req, res) => res.json({ status: "ok", db: true }));
// 先挂无需鉴权的登录/注册
app.use("/api/auth", authRouter);
// 受保护资源:联系人
app.use("/api/contacts", authRequired, contactsRouter);
const port = process.env.PORT || 8000;
app.listen(port, () => console.log(`Server running on http://localhost:${port}`));
联系人列表展示模块
联系人表单模块(添加/编辑)
数据交互模块(API调用)
用户界面交互模块
开发环境配置
安装webstorm,MySQL,JavaScript
配置JavaScript扩展和必要插件
验证开发环境正常运行
项目结构创建
创建前后端分离的目录结构
初始化Git仓库,建立版本控制
配置代码规范文档
设计contact_db表结构
实现MySQL数据库连接
编写数据库初始化脚本
API 服务层实现
使用 Node.js + Express 搭建后端,按 controller / routes / middleware / db 模块化组织,提供统一 API 服务。
实现 RESTful 接口
提供 /api/auth/register、/api/auth/login、/api/contacts(增删改查)等接口,统一使用 JSON 请求/响应。
开启 CORS
启用 cors() 中间件,允许前端域名跨域访问后端接口,支持前后端分离部署。
统一错误与响应格式
集中捕获异常并返回统一结构(如 success/message/code/data),便于前端处理,提升可维护
用户界面设计
设计响应式布局结构
实现联系人列表展示
创建联系人表单组件
业务逻辑实现
实现联系人数据获取和渲染
实现添加联系人功能
实现编辑联系人功能
实现删除联系人功能
前后端联调
测试API接口连通性
验证数据格式一致性
调试跨域请求问题
功能完整性测试
测试联系人CRUD操作
验证数据持久化
检查错误处理机制
项目部署
配置生产环境
部署到云服务器
文档编写
编写技术文档和使用说明
整理项目结构和代码说明
优势:职责清晰,易于维护,支持多端复用
实现:通过API进行数据交换
优势:零依赖,体积小,学习成本低
挑战:需要手动处理DOM操作和事件绑定
优势:无需安装配置,适合作业场景
考虑:虽然性能有限,但完全满足作业需求
CREATE DATABASE IF NOT EXISTS contacts_db
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE contacts_db;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS contacts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
phone VARCHAR(32) NOT NULL,
email VARCHAR(120),
note VARCHAR(255),
user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_contacts_user (user_id),
UNIQUE KEY uk_user_phone (user_id, phone)
);
作用:
创建SQLite数据库文件 contacts_db
定义联系人表结构:
id:主键,自动增长
name:姓名,必填字段
phone:电话,必填字段
email:邮箱,可选字段
note: 备注, 可选字段
// 列表(当前登录用户)
export async function getAll(req, res) {
try {
const uid = req.user?.id;
const [rows] = await db.query(
"SELECT id,name,phone,email,note,created_at,updated_at FROM contacts WHERE user_id=? ORDER BY name COLLATE utf8mb4_zh_0900_as_cs ASC, id ASC",
[uid]
);
res.json(rows);
} catch (e) {
console.error("[contacts/getAll]", e);
res.status(500).json({ error: "服务器内部错误" });
}
}
作用:前端页面加载时调用,返回所有联系人的JSON数据
// 新增
export async function addOne(req, res) {
try {
const uid = req.user?.id;
const { name, phone, email, note } = req.body || {};
const payload = { name: String(name || "").trim(), phone: normPhone(phone), email: email || null, note: note || null };
const bad = validate(payload);
if (bad) return res.status(400).json({ error: bad });
await db.query(
"INSERT INTO contacts (name,phone,email,note,user_id) VALUES (?,?,?,?,?)",
[payload.name, payload.phone, payload.email, payload.note, uid]
);
res.json({ message: "添加成功" });
} catch (e) {
if (e.code === "ER_DUP_ENTRY") return res.status(409).json({ error: "该手机号已在你的联系人中存在" });
console.error("[contacts/addOne]", e);
res.status(500).json({ error: "服务器内部错误" });
}
}
作用:接收前端提交的表单数据,将新联系人保存到数据库
// 更新
export async function updateOne(req, res) {
try {
const uid = req.user?.id;
const id = Number(req.params.id);
const { name, phone, email, note } = req.body || {};
const payload = { name: String(name || "").trim(), phone: normPhone(phone), email: email || null, note: note || null };
const bad = validate(payload);
if (bad) return res.status(400).json({ error: bad });
const [ret] = await db.query(
"UPDATE contacts SET name=?,phone=?,email=?,note=? WHERE id=? AND user_id=?",
[payload.name, payload.phone, payload.email, payload.note, id, uid]
);
if (ret.affectedRows === 0) return res.status(404).json({ error: "未找到或无权限" });
res.json({ message: "Updated" });
} catch (e) {
if (e.code === "ER_DUP_ENTRY") return res.status(409).json({ error: "该手机号已在你的联系人中存在" });
console.error("[contacts/updateOne]", e);
res.status(500).json({ error: "服务器内部错误" });
}
}
作用:根据联系人ID更新对应的联系人信息
// 删除
export async function deleteOne(req, res) {
try {
const uid = req.user?.id;
const id = Number(req.params.id);
const [ret] = await db.query("DELETE FROM contacts WHERE id=? AND user_id=?", [id, uid]);
if (ret.affectedRows === 0) return res.status(404).json({ error: "未找到或无权限" });
res.status(204).send();
} catch (e) {
console.error("[contacts/deleteOne]", e);
res.status(500).json({ error: "服务器内部错误" });
}
}
作用:根据联系人ID从数据库中删除对应的联系人
import "dotenv/config";
import dotenv from "dotenv";
import express from "express";
import cors from "cors";
dotenv.config();
import authRouter from "./backend/src/routes/auth.js";
import contactsRouter from "./backend/src/routes/contacts.js";
import { authRequired } from "./backend/src/middleware/auth.js";
const app = express();
app.use(cors());
app.use(express.json());
// 健康检查(前端“关于系统”会调用)
app.get("/api/health", (_req, res) => res.json({ status: "ok", db: true }));
// 先挂无需鉴权的登录/注册
app.use("/api/auth", authRouter);
// 受保护资源:联系人
app.use("/api/contacts", authRequired, contactsRouter);
const port = process.env.PORT || 8000;
app.listen(port, () => console.log(`Server running on http://localhost:${port}`));
作用:
环境加载:读取 .env 环境变量(端口、数据库配置、JWT 密钥等)。
创建服务:初始化 Express 应用实例。
CORS 配置:开启 cors(),解决前后端分离下的跨域访问问题。
请求解析:启用 express.json(),支持前端以 JSON 方式提交数据。
健康检查:提供 /api/health 接口,供前端检测服务与数据库状态。
鉴权与路由:
/api/auth:挂载登录/注册(无需鉴权)。
/api/contacts:挂载联系人 CRUD,并通过 authRequired 校验 JWT。
启动监听:使用 PORT(默认 8000)启动服务,对外提供 API。
/** ====== 加载与保存 ====== */
async function loadList(){
const r = await apiFetch(API);
if(!r.ok) return;
allRows = await r.json();
applyFilter();
}
function resetForm(){
["id","name","phone","email","note"].forEach(k=>$("#"+k).value="");
}
document.getElementById("resetBtn").onclick = ()=>{ resetForm(); closeEditor(); };
document.getElementById("form").onsubmit = async (e)=>{
e.preventDefault();
const id = $("#id").value || null;
const payload = {
name: $("#name").value.trim(),
phone: $("#phone").value.trim(),
email: $("#email").value.trim(),
note: $("#note").value.trim()
};
const r = await apiFetch(id? `${API}/${id}`: API, {
method: id? "PUT":"POST",
headers:{ "Content-Type":"application/json" },
body: JSON.stringify(payload)
});
const t = await r.json().catch(()=> ({}));
if(!r.ok) return alert(t.error || "保存失败");
resetForm();
await loadList();
closeEditor(); // 保存后自动收起
alert("保存成功");
};
document.getElementById("tbl").addEventListener("click", async (ev)=>{
const btn = ev.target.closest("button"); if(!btn) return;
const tr = ev.target.closest("tr");
const id = tr.dataset.id; // 真实数据库 id(用于请求)
const act = btn.dataset.act;
if(act==="edit"){
$("#id").value = id;
$("#name").value = tr.children[2].textContent.trim();
$("#phone").value= tr.children[3].textContent.trim();
$("#email").value= tr.children[4].textContent.trim();
$("#note").value = tr.children[5].textContent.trim();
openEditor();
}else if(act==="del"){
const seq = tr.querySelector(".idx")?.textContent || "?"; // 页面序号
if(!confirm(`确定要删除 第 ${seq} 条吗?`)) return;
const r = await apiFetch(`${API}/${id}`, { method:"DELETE" });
if(!r.ok) return alert("删除失败");
await loadList();
}
});
关键代码解析:
document.getElementById() / querySelector() 读取/填充表单与表格元素
apiFetch(url, opts) 统一加 Authorization 与 401 登录失效处理
form.onsubmit 判断隐藏域 id:有→PUT /api/contacts/:id,无→POST /api/contacts
tbody.addEventListener('click', ...) 事件委托处理编辑/删除按钮
tr.dataset.id 保存数据库主键,.idx 显示页内序号
renderRows(list.map(...)).join('') 将数据渲染为表格行
applyFilter() 关键词 includes 过滤 + 排序(localeCompare/数值比较)+ 分页 slice()
confirm() 删除二次确认,fetch(...,{method:'DELETE'}) 执行删除
resetForm() 清空表单,openEditor()/closeEditor() 控制编辑区显示
alert(t.error || '保存成功') 统一提示后端返回结果
// 表格:编辑 /删除
document.getElementById("tbl").addEventListener("click", async (ev)=>{
const btn = ev.target.closest("button"); if(!btn) return;
const tr = ev.target.closest("tr");
const id = tr.dataset.id;
const act = btn.dataset.act;
if(act==="edit"){
openEditor();
document.getElementById("id").value = id;
document.getElementById("name").value = tr.children[2].textContent.trim();
document.getElementById("phone").value = tr.children[3].textContent.trim();
document.getElementById("email").value = tr.children[4].textContent.trim();
document.getElementById("note").value = tr.children[5].textContent.trim();
}else if(act==="del"){
const seq = tr.querySelector(".idx")?.textContent || "?";
if(!confirm(`确定要删除 第 ${seq} 条吗?`)) return;
const r = await apiFetch(`${API}/${id}`, { method:"DELETE" });
if(!r.ok) return alert("删除失败");
await loadList();
}
});
关键代码解析:
事件委托:给 #tbl 绑定一次点击,closest('button') 找到被点的按钮,closest('tr') 找到当前行。
取标识:tr.dataset.id 为数据库主键;btn.dataset.act 区分 edit/del。
编辑流:openEditor() → 把行内第 3~6 列文本填入表单 → 把隐藏域 #id 设为真实主键。
删除流:读 .idx 作为界面序号 → confirm() 二次确认 → DELETE /api/contacts/:id → 成功后 loadList() 重新渲染。
注意:表单是否“新增/编辑”取决于隐藏域 #id 是否有值;tr.children[2..5] 索引要与表头列顺序一致。
/** ====== 勾选 & 批量删除 ====== */
document.getElementById("chk-all").onchange = (e)=>{
document.querySelectorAll(".row-chk").forEach(c=> c.checked = e.target.checked);
};
document.getElementById("btn-batch-del").onclick = async ()=>{
const ids = Array.from(document.querySelectorAll(".row-chk:checked"))
.map(c=> c.closest("tr").dataset.id);
if(ids.length===0) return alert("请先勾选要删除的行");
if(!confirm(`确定批量删除 ${ids.length} 条吗?`)) return;
for(const id of ids){
const r = await apiFetch(`${API}/${id}`, { method:"DELETE" });
if(!r.ok){ alert(`删除失败(id=${id})`); break; }
}
await loadList();
};
关键代码解析:
设置主复选框 indeterminate(部分选中态)。
批量接口/并发:后端支持时用 POST /api/contacts/batch-delete 或前端 Promise.allSettled(ids.map(...))。
操作中禁用按钮并显示进度,失败汇总展示。
$("#btn-login").onclick = async ()=>{
const u = $("#lg-username").value.trim(), p = $("#lg-password").value.trim();
if(!u || !p) return alert("请输入用户名和密码");
const r = await fetch(API.replace("/contacts","/auth/login"), {
method:"POST", headers:{ "Content-Type":"application/json" },
body: JSON.stringify({ username:u, password:p })
});
const j = await r.json();
if(!r.ok) return alert(j.error || "登录失败");
setToken(j.token);
goto("#/home");
};
$("#btn-register").onclick = async ()=>{
const u = $("#lg-username").value.trim(), p = $("#lg-password").value.trim();
if(!u || !p) return alert("请输入用户名和密码");
const r = await fetch(API.replace("/contacts","/auth/register"), {
method:"POST", headers:{ "Content-Type":"application/json" },
body: JSON.stringify({ username:u, password:p })
});
const j = await r.json();
if(!r.ok) return alert(j.error || "注册失败");
alert("注册成功,请登录");
};
$("#btn-logout").onclick = ()=>{ clearToken(); goto("#/login"); };
关键代码解析:
#btn-login 读取用户名/密码 → POST /api/auth/login(Content-Type: application/json)→ 成功取回 token,setToken(token) 保存到本地 → goto("#/home") 进入主页。
#btn-register 读取表单 → POST /api/auth/register → 失败弹后端 error,成功提示“注册成功,请登录”。
clearToken() 清除本地令牌 → goto("#/login") 返回登录页。
任何一步若用户名或密码为空,直接 alert("请输入用户名和密码") 并中止。
/** ====== 列表 / 搜索 / 分页 ====== */
let allRows = []; // 后端返回的全部(当前用户)
let viewRows = []; // 搜索/排序后的视图
let pageSize = 10, page = 1;
function applyFilter(){
const kw = $("#kw").value.trim().toLowerCase();
viewRows = allRows.filter(r=>{
const s = `${r.name||""} ${r.phone||""} ${r.email||""} ${r.note||""}`.toLowerCase();
return !kw || s.includes(kw);
});
// 按姓名排序,再按 id
viewRows.sort((a,b)=> (a.name||"").localeCompare(b.name||"", "zh") || a.id - b.id);
page = 1;
render();
}
function render(){
const total = viewRows.length;
const start = (page-1)*pageSize, end = Math.min(start+pageSize, total);
const rows = viewRows.slice(start, end);
const tbody = $("#tbl tbody");
if(!rows.length){
tbody.innerHTML = `<tr><td colspan="7" class="muted" style="padding:14px">暂无数据,试试添加或修改搜索条件。</td></tr>`;
}else{
tbody.innerHTML = rows.map((r, i)=>`
<tr data-id="${r.id}">
<td><input type="checkbox" class="row-chk"></td>
<td class="idx">${start + i + 1}</td> <!-- 页面序号 -->
<td>${r.name||""}</td>
<td>${r.phone||""}</td>
<td>${r.email||""}</td>
<td>${r.note||""}</td>
<td>
<button class="btn success" data-act="edit">编辑</button>
<button class="btn danger" data-act="del">删除</button>
</td>
</tr>`).join("");
}
$("#summary").textContent = `共 ${total} 条,显示 ${total? (start+1) : 0}-${end}`;
renderPager(total);
$("#chk-all").checked = false;
}
function renderPager(total){
const pages = Math.max(1, Math.ceil(total/pageSize));
const el = $("#pager"); el.innerHTML = "";
const mk = (p, txt, cur=false)=>{
const b = document.createElement("button"); b.textContent=txt; if(cur) b.classList.add("current");
b.onclick = ()=>{ page=p; render(); };
el.appendChild(b);
};
mk(Math.max(1,page-1), "上一页");
for(let i=1;i<=pages;i++) mk(i, String(i), i===page);
mk(Math.min(pages,page+1), "下一页");
}
关键代码解析:
allRows 保存后端返回的全集,viewRows 为搜索/排序后的视图;pageSize/page 管分页。
applyFilter() 取关键词,对 allRows 做 includes 过滤;再按姓名 localeCompare('zh') 排序,次序用 id 兜底;重置到第 1 页并 render()。
render() 计算当前页起止索引,用 slice() 取当页数据;表格行里用 data-id 放数据库主键、.idx 显示页内序号;更新统计与全选框状态,然后画分页。
renderPager() 依据总数算总页数,生成“上一页 / 数字页 / 下一页”按钮,点击切页后重新 render()。
这个联系人管理系统实现了完整的CRUD(增删改查)功能:
数据存储:使用JavaScript数组在内存中存储联系人数据
用户界面:通过HTML表单和表格提供用户交互界面
功能实现:
添加联系人:收集表单数据并添加到数组
编辑联系人:将现有数据填充到表单进行修改
删除联系人:从数组中移除指定联系人
查看联系人:在表格中显示所有联系人信息
数据验证:确保姓名和电话字段不为空
本项目成功开发了一个前后端分离的联系人管理系统,圆满完成了作业的所有基础要求。
采用HTML+JavaScript+CSS前端 + JavaScript后端 + MySQL数据库的技术组合,实现了完整的联系人增删改查功能。前后端通过API进行数据交互,架构清晰,代码规范。
✅ 联系人添加、修改、删除功能
✅ 前后端分离架构,数据实时同步
✅ 规范的GitHub仓库管理和代码文档
✅ 简洁易用的Web界面
这个项目不仅实现了具体的功能需求,更重要的是提供了一个完整的前后端分离开发范例。代码结构清晰,技术选型合理,既满足了作业要求,也为后续的Web开发学习奠定了良好基础。
整个项目从环境搭建到功能实现,完整展示了现代Web应用的基本开发流程,是一次很有价值的实践。