2026 秋软件工程结对作业(第二次之程序实现)
从墨刀原型到真能跑的网页 —— 校园失物招领第二次结对作业
| 项目 | 内容 |
|---|---|
| 课程 | 2026-01 软件工程与软件工程实践班级博客 |
| 作业要求 | 2026 秋软件工程结对作业(第二次之程序实现) |
| 作业目标 | 基于上次墨刀原型,结对实现失物招领 Web 程序。 |
| 学号 & 姓名 | 【102401139】张智文、【102401217】颜志航 |
| 结对同学博客链接 | https://www.cnblogs.com/fzuyzh/p/23242437 |
| 本作业博客链接 | https://www.cnblogs.com/platforzation/p/23230728 |
| GitHub 项目地址 | https://github.com/platforzation/102401139-102401217 |
一、具体分工
| 成员 | 承担内容 | 对应提交 |
|---|---|---|
| 张智文(platforzation) | 项目初始化,搭建目录结构与 README | 09bf83e |
| 首页 UI 实现:列表卡片、渐变色块、底部导航,还原墨刀原型 | 0fcb231 | |
| 核心功能:发布 / 搜索 / 详情 / 状态更新、localStorage 持久化、一键复制 | 8df8071 | |
| 编写 19 个单元测试用例 | 1651aab | |
| PR 复审合并 | f1aa1ee | |
| 颜志航(Emrys-m) | 搜索关键词高亮、无结果引导发布 | 4085556 |
| 修复搜索页返回后未重置状态的问题 | 61f9a9c | |
| 增加搜索历史记录与清空功能 | 5b834fc、1f91c4e | |
| 全面美化界面:渐变、阴影、圆角和动效 | ee28f93 | |
| 补充功能说明文档 | 64a7e66、bab8370 | |
| 提交 Pull Request #1 | PR #1 |
协作方式:我先搭好主干框架、数据层和首页 / 详情页面,把公共组件封装完毕;颜志航 Fork 主干后新建 feature/interaction-optimization 分支,专注做搜索体验优化(关键词高亮、搜索历史、无结果引导)和界面美化,写完提 PR;我做代码复审合并,合并后双方确认样式无冲突。
二、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| Estimate | 估计这个任务需要多少时间 | 20 | 15 |
| Development | 开发 | 600 | 860 |
| Analysis | 需求分析(包括学习新技术) | 40 | 70 |
| Design Spec | 生成设计文档 | 30 | 20 |
| Design Review | 设计复审 | 15 | 20 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 10 | 10 |
| Design | 具体设计 | 50 | 80 |
| Coding | 具体编码 | 300 | 480 |
| Code Review | 代码复审(含 PR 合并与样式冲突修复) | 30 | 60 |
| Test | 测试(自我测试,修改代码,提交修改) | 60 | 110 |
| Reporting | 报告 | 60 | 80 |
| Test Report | 测试报告 | 20 | 30 |
| Size Measurement | 计算工作量 | 10 | 10 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 25 | 30 |
| 合计 | 700 | 1050 |
三、解题思路描述与设计实现说明
3.1 代码实现思路
本项目采用 纯前端零依赖架构(原生 HTML + CSS + JavaScript,无框架、无构建工具),双击 index.html 即可在 Chrome 中运行,数据全部保存在浏览器 localStorage 中。
代码分为两层:
页面层 index.html:单页应用(SPA)风格,所有页面(首页 / 发布 / 搜索 / 详情 / 我的 / 发布成功)都在一个 HTML 里,通过 .screen 的 active 类切换显示,对应上次墨刀原型中的 6 个页面。
数据层 js/data.js:全项目唯一的数据入口,统一负责 localStorage 的读写。信息列表使用独立的存储 key(lost_found_items_v2),搜索历史使用另一个 key(lost_found_history_v1),通过版本号管理示例数据升级。所有业务函数(校验、搜索、筛选、状态更新、搜索历史增删)都写成纯函数,不依赖 DOM,方便单元测试。
交互层 js/app.js:负责页面路由、卡片渲染、表单提交、搜索、详情展示、状态更新、关键词高亮渲染、搜索历史管理等 UI 逻辑,通过 IIFE 封装避免全局污染。
3.2 流程图

3.3 有价值代码片段
3.3.1 数据校验(data.js)
发布表单有多个必填字段,校验函数集中收口,返回 {valid, msg} 结构,UI 层只需判断 valid 并 toast 错误信息:
function validateItem(d) {
if (!d.title || !d.title.trim()) return { valid:false, msg:'请填写物品名称' };
if (!d.place || !d.place.trim()) return { valid:false, msg:'请填写地点' };
if (!d.time || !d.time.trim()) return { valid:false, msg:'请填写时间' };
if (!d.contact || !d.contact.trim()) return { valid:false, msg:'请填写联系方式' };
return { valid:true, msg:'' };
}
3.3.2 搜索与筛选(data.js)
搜索同时匹配标题、描述、地点三个字段,不区分大小写;类型筛选和搜索可以叠加使用:
function searchItems(items, kw) {
if (!kw || !kw.trim()) return [];
const k = kw.trim().toLowerCase();
return items.filter(it =>
it.title.toLowerCase().includes(k) ||
(it.desc||'').toLowerCase().includes(k) ||
it.place.toLowerCase().includes(k)
);
}
function filterByType(items, type) {
if (!type || type === 'all') return items.slice();
return items.filter(it => it.type === type);
}
3.3.3 搜索关键词高亮(app.js)
搜索结果中命中的关键词用 <mark> 标签包起来高亮显示,让用户一眼看到匹配位置:
function highlight(text, kw) {
if (!kw) return escapeHtml(text);
const reg = new RegExp('(' + kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return escapeHtml(text).replace(reg, '<mark>$1</mark>');
}
先 escapeHtml 转义防止 XSS,再用正则替换命中的关键词,最后包上 <mark>。正则里对特殊字符做了转义,防止关键词带 .、* 等字符时报错。
3.3.4 搜索历史(data.js)
每次执行搜索就把关键词存进 localStorage,最多保留 10 条,新的排前面,重复的自动去重:
function addHistory(kw) {
if (!kw || !kw.trim()) return;
let h = getHistory().filter(x => x !== kw);
h.unshift(kw);
if (h.length > 10) h = h.slice(0, 10);
localStorage.setItem(HISTORY_KEY, JSON.stringify(h));
}
搜索页顶部展示历史词云,点击直接搜索,旁边有 "清空" 按钮。
3.3.5 一键复制联系方式(app.js)
优先使用 Clipboard API,在 file:// 双击打开场景下自动降级为隐藏 textarea + execCommand,保证所有环境都能复制:
function copyContact() {
const d = loadItems().find(x => x.id === currentDetailId);
if (navigator.clipboard) {
navigator.clipboard.writeText(d.contact).then(
() => showToast('已复制联系方式'),
() => fallbackCopy(d.contact)
);
} else fallbackCopy(d.contact);
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position='fixed'; ta.style.opacity='0';
document.body.appendChild(ta); ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
四、附加特点设计与展示
4.1 用颜色传递语义,而不是只靠标签
我们没有给所有卡片用同一种颜色,而是给 "寻物" 和 "招领" 设计了两套渐变色块:
-
寻物启事:红色渐变(
#FCA5A5 → #EF4444)—— 红色本身带 "着急、寻找、紧急" 的情绪 -
招领信息:绿色渐变(
#6EE7B7 → #10B981)—— 绿色带 "希望、安心、物归原主" 的情绪
用户在列表里扫一眼,不用读文字就能凭颜色区分 "这是丢了东西的" 还是 "这是捡到东西的"。状态胶囊也用了同样的思路:进行中是黄色(#FEF3C7,等待中),已解决是灰色(#E5E7EB,事已了)。已解决的整张卡片还会自动降低透明度到 0.6,视觉上自动 "退到后面",不干扰有效信息。

4.2 单页应用:六个页面,零刷新
整个应用的六个页面(首页 / 发布 / 搜索 / 详情 / 我的 / 发布成功)都在一个 index.html 里,通过切换 .screen 的 active 类来显示。好处是:
-
点 "发布" 再返回首页,列表数据还在,不用重新加载
-
页面切换没有白屏,像真 App 一样流畅
-
不需要路由库、不需要服务器,双击 HTML 就能跑
4.3 emoji 图标代替照片上传
原型里设计了 "上传物品照片" 的虚线框,但纯前端项目用 localStorage 存 base64 图片很快就会撑爆(一张图就几百 KB)。我们改成了12 个常用 emoji 图标选择器(💳🔑📱🎧☂️📚💧🧢🎒🪪🔌📦),发布时点一下就选好了。卡片上、详情页顶部都会显示对应的大图标,既轻量又直观,也避免了 "上传了一张无关照片" 的尴尬。

4.4 搜索体验三连:历史记录 + 关键词高亮 + 无结果引导
-
搜索历史:每次搜索自动存词,最多 10 条,去重倒序排列。用户下次打开搜索页能直接点历史词云,不用重新打字。
-
关键词高亮:搜索结果里命中的关键词用黄色
<mark>标出来,用户扫一眼就知道为什么这条结果被匹配上了,不用自己在长描述里找。 -
无结果引导发布:搜不到东西时不只是显示 "暂无结果",而是给一个 "我要发布这条信息" 的按钮,直接跳到发布页并自动把搜索词填进标题,把 "找不着" 的流量转化成 "我来发一条"。

五、目录说明和使用说明
5.1 目录说明
lost-and-found/
├── index.html # 主页面(单页应用,含首页/发布/搜索/详情/我的/成功页)
├── test.html # 单元测试页(双击直接看测试报告)
├── README.md # 项目说明
├── css/
│ └── style.css # 全部样式(蓝色主题,移动端优先,最大宽度 420px 居中)
└── js/
├── data.js # 数据层:localStorage 读写、校验、搜索、筛选、状态更新、搜索历史
├── app.js # UI 交互:路由、卡片渲染、表单、搜索、详情、关键词高亮
└── test.js # 单元测试用例(19+ 个)
组织原则:数据与视图分离。data.js 只做数据操作,写成纯函数不依赖 DOM;app.js 只负责界面渲染和事件绑定。这样既符合 MVC 思想,也方便对 data.js 中的纯函数直接做单元测试。
5.2 测试人员如何运行
双击 index.html(用 Chrome 打开)
↓
首页(自动加载 5 条示例数据)
↓
┌────────┬─────────┬──────────┐
↓ ↓ ↓ ↓
浏览列表 搜索 发布 查看详情
(tab切换 (点顶部 (点底部 (点任意卡片
寻物/招领)搜索框) 中间+号) 可复制联系方式)
↓ ↓ ↓
填写表单 我的发布
选图标 (底部"我的")
填信息 ↓
提交成功 标记已找到/已归还
本项目为纯静态网页,进入 lost-and-found 目录,用 Google Chrome 双击 index.html 即可启动,无需安装任何环境。首次打开会自动预置 5 条示例数据,可直接浏览、搜索、发布。
六、单元测试
6.1 测试工具的选用
本项目为纯前端 JavaScript 项目(无后端、无构建工具),我们没有引入 Mocha/Jest 等重型框架,而是自己写了一个极简浏览器端测试框架:打开 test.html 自动执行所有用例,页面上直接显示通过 / 失败结果。
| 工具 | 作用 | 选择理由 |
|---|---|---|
| 原生断言 | assert(condition, name) |
零依赖,双击 HTML 就能跑,符合作业 "双击 HTML 即可运行" 的要求 |
| 浏览器直接运行 | test.html 加载 data.js + test.js | 不需要 Node.js、npm,测试同学打开就能看结果 |
学习过程:我们参考了廖雪峰的 JavaScript 教程和阮一峰的 Mocha 教程,理解了单元测试的基本思想(断言、用例组织、白盒覆盖)。考虑到本项目要求 "双击 HTML 就能跑",引入 Mocha 需要 Node 环境反而增加测试同学的负担,因此选择了零依赖方案。
6.2 测试用例展示
我们对 data.js 中的核心纯函数设计了 19 个测试用例,覆盖校验、搜索、筛选、状态更新等:
// validateItem:必填字段校验
eq(validateItem({title:'', place:'x', time:'t', contact:'c'}).valid, false, 'T1 空名称应报错');
eq(validateItem({title:'x', place:'', time:'t', contact:'c'}).valid, false, 'T2 空地点应报错');
eq(validateItem({title:'x', place:'x', time:'t', contact:''}).valid, false, 'T4 空联系方式应报错');
eq(validateItem({title:'x', place:'x', time:'t', contact:'c'}).valid, true, 'T5 合法数据应通过');
// searchItems:搜索
eq(searchItems(sample,'校园卡').length, 1, 'T6 按名称搜索');
eq(searchItems(sample,'airpods').length, 1, 'T7 大小写不敏感');
eq(searchItems(sample,'图书馆').length, 1, 'T8 按地点搜索');
eq(searchItems(sample,'不存在xyz').length, 0, 'T10 无匹配返回空');
// filterByType:类型筛选
eq(filterByType(sample,'lost').length, 2, 'T11 筛寻物');
eq(filterByType(sample,'found').length, 1, 'T12 筛招领');
// updateStatus:状态更新
assert(updateStatus(it.id,'done') === true, 'T17 updateStatus成功');
assert(updateStatus('notexist','done') === false, 'T19 不存在id返回false');
6.3 测试用例汇总与 "刁难" 考虑
| 编号 | 被测函数 | 用例类型 | 测试数据 | 预期结果 |
|---|---|---|---|---|
| T1 | validateItem | 反例 | 名称为空 | 报错 |
| T2 | validateItem | 反例 | 地点为空 | 报错 |
| T3 | validateItem | 反例 | 时间为空 | 报错 |
| T4 | validateItem | 反例 | 联系方式为空白 | 报错 |
| T5 | validateItem | 正例 | 全部字段合法 | 通过 |
| T6 | searchItems | 正例 | "校园卡" | 匹配 1 条 |
| T7 | searchItems | 边界 | "airpods"(小写) | 匹配 AirPods |
| T8 | searchItems | 正例 | "图书馆"(地点) | 匹配 1 条 |
| T9 | searchItems | 边界 | 空关键词 | 返回空 |
| T10 | searchItems | 反例 | 不存在的词 | 返回空 |
| T11 | filterByType | 正例 | lost | 筛出 2 条 |
| T12 | filterByType | 正例 | found | 筛出 1 条 |
| T13 | filterByType | 边界 | all | 返回全部 |
| T14 | genId | 刁难 | 连续生成 100 个 | 全部唯一 |
| T15 | addItem | 正例 | 新增一条 | 能读到 |
| T16 | addItem | 正例 | 新增条目 | 状态为 open |
| T17 | updateStatus | 正例 | 存在的 id | 返回 true |
| T18 | updateStatus | 正例 | 更新后 | 状态变 done |
| T19 | updateStatus | 刁难 | 不存在的 id | 返回 false |
如何应对测试人员的刁难:
-
空输入:名称 / 地点 / 联系方式为空时 toast 提示,不报错;
-
大小写:搜索 "airpods" 和 "AirPods" 都能匹配;
-
不存在的 id:直接调用 updateStatus 传不存在的 id,返回 false 不崩溃;
-
疯狂发布:连续发布多条,列表正确追加;
-
重复操作:状态可以在 open/done 之间来回切换,不会出错。
七、GitHub 签入记录

八、遇到的问题及解决方法
问题描述:
-
上次墨刀原型是在一个手机外壳里展示的高保真 demo,所有数据都是写死在 JS 里的假数据。这次要变成真能用的网页,需要把静态数据换成 localStorage 动态数据,但一开始不知道怎么把原型里的多个 "页面" 在一个 HTML 里切换。
-
队友提 PR 合并后,出现了样式冲突:队友在
style.css里加了渐变和阴影效果,和主干已有的卡片样式重叠,导致首页列表卡片圆角不一致。 -
搜索页返回首页后,再点进搜索页,输入框还留着上次的关键词,搜索结果却被清空了,状态不一致。
做过哪些尝试:
-
一开始想做成多个 HTML 文件(index.html/publish.html/detail.html),但这样每个页面都要重复写头部、底部导航,代码冗余。后来参考原型里的
.screen切换思路,改成单页应用,用display: none/flex控制显示哪个页面。 -
合并 PR 后双方走查首页,逐卡片对比圆角和阴影,定位到是队友新加的
.card样式和主干原有的.lost-card选择器优先级冲突。把样式统一到一个类下,删掉重复定义。 -
在
showScreen('search')函数里每次进入搜索页都重置输入框和结果列表,保证进入搜索页时状态干净。
是否解决:
都已解决。
有何收获:
理解了单页应用(SPA)的基本原理 —— 本质上就是所有页面都在一个 HTML 里,通过 CSS 控制显隐,JS 模拟路由。也体会到了代码复审的重要性:队友提 PR 时自己没注意样式冲突,合并后走查才发现,以后合并前要先在本地拉下来跑一遍。
九、评价你的队友
值得学习的地方:
队友对搜索体验的细节很敏感,主动加了关键词高亮、搜索历史、无结果引导发布这三个功能,还顺便把界面做了一轮美化(渐变、阴影、动效),比我只想着 "功能跑通就行" 想得更远。Git 操作也很熟练,自己建分支、提 PR、写 commit message,流程规范。
需要改进的地方:
合并 PR 后暴露出样式冲突,说明他在自己分支上改样式时没有对照主干最新的 CSS,以后合并前应该先 pull 主干最新代码跑一遍再提 PR。

浙公网安备 33010602011771号