软件工程结对作业(第二次之程序实现)
FZU校园失物招领平台——结对编程博客
一、链接与分工
| 项目内容 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| 这个作业的目标 | 完成校园失物招领小程序的开发 |
| 学号 | 10240205-102401204 |
| GitHub 仓库 | https://github.com/chen-yeling/102401205-102401204 |
具体分工
| 成员 | 学号 | 负责内容 |
|---|---|---|
| 陈烨婷 | 102401205 | 首页、发布页、详情页、代码整合、视图切换、表单校验、图片压缩、单元测试 |
| 张楠 | 102401204 | 我的页面,精细加工其他页面(如类别筛选、一键复制),单元测试、README |
二、PSP表格
| PSP阶段 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| 计划 | 30 | 40 |
| 需求分析 | 40 | 50 |
| 设计页面与数据结构 | 60 | 80 |
| 编写HTML骨架 | 90 | 120 |
| 编写CSS样式 | 180 | 240 |
| 编写JS核心逻辑 | 240 | 320 |
| 单元测试 | 90 | 120 |
| 解决Git冲突与合并 | 60 | 100 |
| README与博客撰写 | 120 | 150 |
| 总计 | 910 | 1220 |
三、解题思路与设计实现
3.1 代码实现思路
本项目采用单页面多视图(SPA)架构,所有功能都在一个 index.html 中完成,通过切换 .view 的 active 类来展示不同页面,避免页面跳转带来的状态丢失。
四个视图
1. 首页 #homeView
功能:搜索、类型筛选、类别筛选、卡片列表
2. 发布页 #publishView
功能:发布表单、图片上传、表单校验
3. 详情页 #detailView
功能:显示完整信息、一键复制联系方式
4. 我的发布 #mineView
功能:管理自己发布的信息、标记“已找到/已归还”、删除
模块划分:
storage.js:只负责 localStorage 读写,不碰页面core.js:纯逻辑函数(搜索、筛选、状态更新、删除),不碰 DOM,方便单元测试ui.js:渲染列表、切换视图、绑定事件,是连接数据和页面的桥梁app.js:入口文件,DOMContentLoaded时启动
3.2 关键数据流图
3.3 用户操作主流程图
3.4 重要代码片段
片段一:搜索 + 类型 + 类别三重筛选
function render() {
const allItems = loadItems();
allItems.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
let items = searchItems(allItems, currentKeyword);
items = filterItems(items, currentType);
items = filterByCategory(items, currentCategory);
// ...
}
这段代码把三种筛选条件串联使用,每一层都在上一层的输出上继续过滤,实现了“搜索词 + 类型 + 类别”的组合筛选。
片段二:图片压缩存储
function compressImage(file, callback) {
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = function () {
const canvas = document.createElement("canvas");
let w = img.width, h = img.height;
const maxW = 600;
if (w > maxW) { h = h * maxW / w; w = maxW; }
canvas.width = w;
canvas.height = h;
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
callback(canvas.toDataURL("image/jpeg", 0.7));
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
由于 localStorage 只有约 5MB 容量,直接存原图会爆掉。通过 canvas 将图片缩放到最大 600px 宽,并以 JPEG 0.7 质量压缩,一张图约 30~80KB,可存十几张。
片段三:删除按钮的事件委托
mineList.addEventListener("click", (e) => {
const deleteBtn = e.target.closest(".delete-btn");
if (deleteBtn) {
e.stopPropagation(); // 阻止冒泡,避免触发卡片点击
const id = deleteBtn.dataset.id;
if (!confirm(`确定删除「${name}」吗?`)) return;
saveItems(deleteItem(loadItems(), id));
renderMine();
render();
return;
}
// ... 处理标记、点击卡片
});
因为“我的发布”列表是 JS 动态生成的,每次删除后 DOM 会重新渲染,所以用事件委托在父容器 #mineList 上监听点击,避免重复绑定。
四、附加特点设计
4.1一键复制联系方式
创意独到之处:详情页联系方式旁增加“复制”按钮,点击即可将联系方式复制到剪贴板,并弹出 Toast 提示。
意义:用户无需长按选中或手动抄写联系方式,一步即可复制,方便后续联系发布者。
实现思路:优先使用现代 navigator.clipboard.writeText API,不支持时降级为 document.execCommand("copy")。
function fallbackCopy(text) {
const input = document.createElement("textarea");
input.value = text;
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
try {
const ok = document.execCommand("copy");
showToast(ok ? "已复制联系方式:" + text : "复制失败,请手动复制");
} catch (e) {
showToast("复制失败,请手动复制");
}
document.body.removeChild(input);
}
成果展示:见3.1的#detailView视图
4.2 物品类别筛选
创意独到之处:发布时选择“证件卡类 / 电子产品 / 书籍资料 / 衣物饰品 / 钥匙 / 其他”,首页可通过横向滚动的胶囊按钮按类别筛选,缩小查找范围。
意义:用户不必在大量信息中逐条翻找,选择类别后列表立刻过滤,提升查找效率。
实现思路:
- 发布表单增加
<select name="category">并设为必填。 core.js新增filterByCategory函数。- 首页增加类别按钮栏,点击后更新
currentCategory并重新渲染。 - 类别栏下方使用灰色可拖动的横向滚动条,方便触摸和鼠标操作。
function filterByCategory(items, category) {
if (!category || category === "all") return items;
return items.filter(item => item.category === category);
}
function switchCategory(category) {
currentCategory = category;
document.querySelectorAll("#categoryBar .cat-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.category === category);
});
render();
}
成果展示:见3.1的#homeView视图
五、目录说明与使用说明
5.1 目录组织
102401205-102401204/
├── index.html # 单页面,包含4个视图
├── css/
│ └── style.css # 所有样式
├── js/
│ ├── storage.js # 数据读写层
│ ├── core.js # 纯逻辑函数
│ ├── ui.js # 渲染 + 事件绑定
│ └── app.js # 入口
├── test/
│ ├── test.html # 单元测试运行页面
│ └── test.js # 27 个断言,覆盖 6 个核心逻辑函数
└── README.md # 说明文档
分层原则:数据层(storage)→ 逻辑层(core)→ 视图层(ui)→ 入口(app),层与层之间单向依赖,便于维护和测试。
5.2 测试人员如何运行
- 下载所有文件到本地文件夹
- 用 谷歌浏览器 打开
index.html- 若直接双击后页面异常,请安装 VS Code 的
Live Server插件,右键index.html→Open with Live Server
- 若直接双击后页面异常,请安装 VS Code 的
- 打开后默认在首页,点击底部 + 可以发布第一条信息
- 点卡片看详情,点“我的”管理发布
六、单元测试
6.1 测试工具与学习教程
选用工具:原生 JavaScript + 自定义极简断言函数。没用 Jest/Mocha,因为项目的核心逻辑(createItem、searchItems、filterItems、filterByCategory、updateItemStatus、getStatusText)都是纯函数,不依赖 DOM,用几行代码就能完整验证,也方便零基础同学理解。
写给零基础同学的简易教程:
单元测试的核心只有一件事——给函数一个输入,断言它的输出符合预期。
let passed = 0, total = 0;
function assert(condition, message) {
total++;
if (condition) {
passed++;
console.log("✅ 通过:" + message);
} else {
console.error("❌ 失败:" + message);
}
}
condition:要验证的条件,如item.type === "lost"message:用例名,方便定位失败项
三步跑起来:
- 准备假数据(
mockData) - 给每个函数写
assert(...) - 浏览器打开
test/test.html,F12 看 Console
passed === total 时页面显示绿色,说明全部通过。
6.2 测试代码展示
test/test.html(运行页面):
<script src="../js/core.js"></script>
<script src="test.js"></script>
<div id="result">测试运行中...</div>
test/test.js(仅展示代表性用例,完整 27 个断言见 GitHub):
// ---- 测试框架 ----
let passed = 0, total = 0;
function assert(cond, msg) {
total++;
if (cond) { passed++; console.log("✅ " + msg); }
else { console.error("❌ " + msg); }
}
// ---- 假数据 ----
const mockData = [
{ id: "1", type: "lost", title: "校园卡", category: "证件卡类",
place: "图书馆三楼", desc: "在图书馆丢失", status: "active" },
{ id: "2", type: "found", title: "一串钥匙", category: "钥匙",
place: "第二食堂", desc: "在食堂捡到", status: "active" }
];
// ---- createItem:默认值与字段 ----
const item = createItem({ type: "lost", title: "蓝牙耳机", category: "电子产品" });
assert(item.status === "active", "createItem:默认状态 active");
assert(typeof item.id === "string", "createItem:自动生成 id");
// ---- searchItems:命中、无结果、空搜索 ----
assert(searchItems(mockData, "校园卡").length === 1, "searchItems:按名称命中");
assert(searchItems(mockData, "手机").length === 0, "searchItems:无结果返回空");
assert(searchItems(mockData, " ").length === 2, "searchItems:空搜索返回全部");
// ---- filterItems:三种类型 ----
assert(filterItems(mockData, "lost").length === 1, "filterItems:筛选寻物");
assert(filterItems(mockData, "all").length === 2, "filterItems:all 返回全部");
// ---- filterByCategory:类别筛选 ----
assert(filterByCategory(mockData, "证件卡类").length === 1, "filterByCategory:命中");
assert(filterByCategory(mockData, "").length === 2, "filterByCategory:空值返回全部");
// ---- updateItemStatus:更新 + 不变性 ----
const updated = updateItemStatus(mockData, "1", "found");
assert(updated[0].status === "found", "updateItemStatus:状态更新");
assert(updated[1].status === "active", "updateItemStatus:其他项不受影响");
// ---- getStatusText:状态码转中文 ----
assert(getStatusText({ status: "found" }) === "已找到", "getStatusText:found→已找到");
// ---- 输出结果 ----
document.getElementById("result").textContent =
`测试完成:通过 ${passed} / ${total} 个用例`;
完整版共 27 个断言,覆盖 6 个核心函数,见 GitHub 仓库 test/test.js。
6.3 测试函数说明
| 编号 | 被测试函数 | 测试重点 |
|---|---|---|
| 1 | createItem |
字段完整性、默认值、自动生成 id |
| 2 | searchItems |
名称/地点/描述匹配、空搜索、无结果 |
| 3 | filterItems |
lost / found / all 三种筛选 |
| 4 | filterByCategory |
类别筛选、空字符串兼容 |
| 5 | updateItemStatus |
状态更新、其他项不受影响(不变性) |
| 6 | getStatusText |
状态码转中文文案 |
6.4 构造测试数据的思路
三类场景全覆盖:
| 类型 | 说明 | 举例 |
|---|---|---|
| 正常数据 | 主流路径,必须有 | 搜索"校园卡"返回 1 条 |
| 边界数据 | 临界情况,最容易出 bug | 空搜索、空类别、无结果 |
| 不变性验证 | 防止函数有副作用 | 更新一条时其他条不能变 |
预演"测试人员的刁难":
| 刁难 | 我们的应对 |
|---|---|
| 搜索词带空格 | searchItems 内部 trim() |
| 全大写搜索 | toLowerCase() 做不敏感匹配 |
| 不传类别 | createItem 默认 "" |
| 快速点两次发布 | submitBtn.disabled = true 防重复 |
输入 <script> 注入 |
渲染时 escapeHtml 转义 |
6.5 运行方式
- 用 Chrome 打开
test/test.html - 页面顶部显示结果,F12 看每条用例
- 全绿 → 说明核心逻辑无误
预期结果:

失败时:Console 会显示红色 ❌ 和用例名,去 js/core.js 改对应函数,刷新重跑。
6.6 与作业要求的对应
作业要求"至少 5 个测试用例",我们实际写了 27 个断言,覆盖 6 个核心函数,远超最低要求。
作业说"单元测试不必上传",但为了方便助教和同学验证,我们把 test/ 目录一并上传到了 GitHub,并在 README 中注明了运行方式。
七、GitHub代码签入记录
八、异常与困难
8.1 问题描述
问题一:红线初次加载不显示
在 DOMContentLoaded 时调用 moveIndicator(),此时布局尚未完成,getBoundingClientRect() 返回 0,红线宽度为 0。
问题二:Git 合并冲突
两人分别改动了 index.html 和 ui.js,pull request 时出现多处 <<<<<<< 冲突标记。
问题三:详情页返回后按钮偏左
从详情页返回首页时,showView("homeView") 刚把视图从 display: none 切到 flex,布局未稳定就调用 moveIndicator,导致位置错误。
8.2 做过的尝试
- 尝试用
setTimeout(0):仍然过早 - 尝试
setTimeout(100):基本可用,但不够严谨 - 最终用 双层
requestAnimationFrame或setTimeout(100)+ 手动调一次
setTimeout(() => {
const indicator = document.querySelector(".tab-indicator");
if (indicator) {
indicator.style.transition = "none";
moveIndicator();
void indicator.offsetWidth;
indicator.style.transition = "";
}
}, 100);
8.3 是否解决
已解决。红线初次加载即出现在正确位置,切换与返回时平滑移动。Git 冲突通过手动保留双方有效代码 + 删除所有冲突标记解决。
8.4 收获
- 理解了浏览器渲染时序:
DOMContentLoaded不等于布局完成,需要requestAnimationFrame或setTimeout让出主线程 - 学会了Git 冲突解决流程:定位标记 → 取舍代码 → 删除标记 → 提交 → push
- 掌握了分层架构的好处:
core.js不碰 DOM,所以可以脱离浏览器单独跑单元测试
九、评价队友
张楠(102401204)
值得学习的地方:
- 单元测试写得很细致,覆盖了正常、边界、异常多种情况
- 类别筛选栏的创意很好,用横向滚动 + 胶囊按钮,视觉上很清爽
- README 文档结构清晰,方便助教和其他同学上手
需要改进的地方:
- 一开始提交的 commit message 不够规范(有的只有“更新”两个字),后来改成
feat:开头 - 修改 CSS 时习惯新增而不是替换,导致红线样式重复了三段,后期需要手动清理
陈烨婷(102401205,自我评价)
- 负责了主要页面实现和代码整合,遇到问题时习惯先查 F12 Console 报错定位
- 需要改进的是:写代码时对布局时序问题的预判不够,红线位置问题绕了三次才彻底解决
十、总结
本项目基于第一次结对作业的原型,用纯前端技术完成了“发布 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态”的完整流程。通过分层架构(storage / core / ui / app)让代码职责清晰,通过单元测试保证了核心逻辑的可靠性。虽然过程中遇到了 Git 冲突、布局时序、localStorage 容量等问题,但都在两人协作下逐一解决。

八、异常与困难
浙公网安备 33010602011771号