从原型到代码:校园失物招领小程序的结对实现
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现) |
| github地址 | 052403108-072401214 |
| 本博客链接 | 我的博客 |
| 队友博客 | 队友的博客 |
二.分工
第一阶段:需求分析与接口约定
首先共同了解第二轮结对编程的要求,明确了本阶段只需完成“发布—浏览—搜索—联系—更新状态”的核心闭环。在此基础上,我们共同设计了系统的数据模型,约定了全站数据和个人发布数据两个核心数组的字段结构,以及 saveData()、renderHome()等公共接口的调用规范。
第二阶段:项目初始化与视图骨架(王凯凤主导,王恩嘉参与)
A同学负责搭建工程化的目录结构,主要编写 index.html的全局骨架,完成了登录、首页、搜索等六个屏幕容器的布局,同时负责 style.css 的全局样式编写。B同学初始化data.js中的默认数据,并配置了本地开发环境。在此阶段进行第一次代码合并。
第三阶段:核心业务逻辑与交互实现(共同参与)
A同学主要负责交互层与视图渲染,B同学主要负责数据层与业务逻辑
第四阶段:自动化测试与异常排查(王恩嘉主导,王凯凤协助)
B同学主导编写了 assert.js轻量级断言库和 test.html 自动化测试运行器,设计了 白盒测试用例。A同学协助排查测试环境与主程序的耦合问题,比如解决因测试页面缺失 DOM 元素导致 app.js 初始化报错的问题。
第五阶段:文档整理与最终交付(共同参与)
B同学负责撰写博客,将开发过程中的流程图、数据流图、核心代码片段和测试评估整理成文。A同学负责收集真实运行截图,完善 PSP 表格中的实际耗时记录,并撰写个人结对感悟。
三.PSP
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 40 |
| Estimate | 估计这个任务需要多少时间 | 10 | 10 |
| Development | 开发 | 330 | 470 |
| Analysis | 需求分析(包括学习新技术) | 45 | 60 |
| Design Spec | 生成设计文档 | 15 | 30 |
| Design Review | 设计复审 | 20 | 30 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 20 | 30 |
| Design | 具体设计 | 30 | 40 |
| Coding | 具体编码 | 120 | 180 |
| Code Review | 代码复审 | 20 | 40 |
| Test | 测试(自我测试,修改代码,提交修改) | 60 | 60 |
| Reporting | 报告 | 40 | 60 |
| Test Report | 测试报告 | 15 | 30 |
| Size Measurement | 计算工作量 | 10 | 15 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 15 | 15 |
| 合计 | 400 | 570 |
四.解题思路描述与设计实现说明
4.1 代码实现思路
本项目的核心目标是实现一个“校园失物招领”单页应用。我们没有引入复杂的后端框架,而是采用采用原生 HTML、CSS 和 JavaScript 来构建一个轻量级的单页应用,并借助浏览器的 LocalStorage 实现数据的持久化存储;并通过在 index.html 中控制各个 screen 容器的显隐来实现无刷新的页面切换。
数据流管理上,我们遵循单向数据流的原则,所有的用户操作如发布、删除或标记解决,都会先去修改全局的 DATA 和 MY_POSTS 数组,然后调用 saveData 函数将数据持久化到本地,最后触发对应的渲染函数重新绘制视图,保证了数据与视图的一致性。
在核心功能的具体实现上,发布模块是我们投入精力较多的部分。为了让用户能够直观地上传图片,我们利用 HTML5 的 FileReader 将用户选择的本地图片转换成了 Base64 编码格式。这种做法的好处在于无需任何服务器即可完成图片的本地化预览与存储。
在搜索功能的实现中,我们利用了Array.prototype.filter 方法。通过监听搜索框的 input 事件,系统会实时获取用户输入的关键词,并对全局数据源进行遍历和过滤。为了提升查全率,采用模糊匹配策略,用 includes 方法同时比对物品的名称、地点和描述三个字段。只要任意一个字段包含关键词,该条数据就会被保留,并立即触发视图的重新渲染。如果搜索结果为空,我们也没有仅仅展示空白页,而是设计了友好的空状态提示,并提供跳转到发布页的快捷链接,形成良好的操作引导。
在“我的发布”与状态流转模块中,对于用户发布的帖子,在数据结构中定义了 showing 和 hidden 两种状态。当用户点击“标记已找回”或“删除”时,程序并不会直接销毁所有数据,而是先修改个人数据列表中对应帖子的状态字段。然后系统会同步从全局的 DATA 数据源中移除该条记录,使首页和搜索页不再展示。
4.2 流程图

如图所示,该程序采用分层的架构设计,整个项目的数据流是单向且闭环的。在交互层,用户通过发布、删除或标记已解决等操作修改数据;随后,业务逻辑会调用数据层的 saveData() 函数,将最新的 DATA 和 MY_POSTS 数组序列化并持久化到持久层(LocalStorage)中,确保刷新页面不丢失。当应用再次初始化或需要刷新视图时,系统会从持久层读取数据,并触发视图层的渲染函数(如 renderHome、renderSearch),最终将数据动态映射到 DOM 节点上。这种分层架构不仅确保了数据流转的清晰可追踪,也提升了代码的健壮性和可维护性。
4.3 重要的代码段
1.基于 Base64 与 FileReader 的图片本地化存储
if (file.size > 2 * 1024 * 1024) {
toast('图片不能超过 2MB');
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(e) {
uploadedImageBase64 = e.target.result;
// ...(后续更新界面预览的代码)
};
reader.readAsDataURL(file);
由于课程作业没有后台服务器,传统的图片上传方式无法使用。为了解决这个问题,我们采用 HTML5 提供的 FileReader API,将用户选择的本地图片直接转换成 Base64 编码字符串,并存储在浏览器的 LocalStorage 中。这让纯前端项目也能拥有真实的图片上传和持久化预览功能。同时,我们特别增加了 2MB 的边界值校验,因为浏览器的 LocalStorage 容量通常只有 5-10MB,如果不加限制,用户上传几张高清图就会导致整个应用崩溃。
2.无服务器下的数据持久化
let DATA = JSON.parse(localStorage.getItem(STORAGE_KEY_DATA));
if (!DATA || DATA.length === 0) {
DATA = [...DEFAULT_DATA];
}
function saveData() {
localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(DATA));
localStorage.setItem(STORAGE_KEY_MINE, JSON.stringify(MY_POSTS));
}
通过封装 saveData() 函数,在每次发布、删除或标记解决的业务逻辑末尾调用,实现了数据的实时持久化。同时,如果用户第一次打开页面,或者手动清理了浏览器缓存,代码会判断数据为空,并自动加载预先设置好的 DEFAULT_DATA,保证了测试人员在任何情况下打开网页,都能看到一个完整、有数据的演示界面。
五.附加特点设计与展示
5.1 设计的创意
我们设计了名为“智能匹配推送”的附加功能。在这个设计的创意独到之处与意义方面,我们观察到传统失物招领平台普遍存在一个问题:丢失物品的人往往只能自己去搜索,而由于每个人对物品的描述习惯不同,很容易因为关键词不匹配而错过。为了打破这种被动局面,我们设计了前端智能匹配机制。它的核心意义在于将传统的“人找信息”转变为“信息找人”,当用户发布寻物信息时,系统主动帮他们寻找潜在的匹配项,提高了物品找回的效率。
5.2 实现思路
当用户在发布页点击提交,且提交类型被判定为“寻物”时,系统并不会立刻弹出普通的发布成功提示,而是会调用 findMatchNotice 函数,遍历当前数据库中的所有“招领”数据。匹配策略分为两步:首先是对比物品名称,采用双向包含的模糊匹配(例如用户输入“钥匙”,能匹配到已有的“钥匙串(5把)”);其次是对比物品分类是否一致。如果两者满足其一,系统就会将其视为疑似匹配项,并弹出一个提示窗口,引导用户一键跳转到该招领信息的详情页去联系对方。
5.3 代码
将该功能拆分成了匹配算法与业务触发两段核心代码。先在 findMatchNotice 函数中实现了核心的匹配逻辑。如代码所示,仅在用户发布“寻物”信息时触发,利用 Array.prototype.filter 遍历全局数据源,排除用户自身的帖子以及非“招领”类型的数据。在匹配策略上采用模糊匹配:通过双向 includes 判断物品名称是否互相包含,同时交叉验证分类字段是否一致。最终,函数会返回匹配到的第一条数据或返回 null。
function findMatchNotice(newItem) {
if (newItem.type !== '寻物') return null;
const matches = DATA.filter(item => {
if (item.type !== '招领' || item.id === newItem.id) return false;
const nameMatch = item.name.includes(newItem.name) || newItem.name.includes(item.name);
const categoryMatch = item.category && newItem.category && item.category === newItem.category;
return nameMatch || categoryMatch;
});
return matches.length > 0 ? matches[0] : null;
}
完成底层算法后,将其集成到主发布流程 publish 函数中。主流程只需调用函数并根据返回结果进行 if-else 的分支分发。如果匹配成功,系统会动态拼接出一个特殊的成功弹窗,弹窗中不仅展示了匹配到的物品名称和地点,还动态注入了一个带有 openDetail(${matchedItem.id}) 事件的按钮,引导用户一键跳转查看;如果未匹配到,系统则平滑回退到普通的“发布成功”提示。
const matchedItem = findMatchNotice(newItem);
if (matchedItem) {
showSuccess({
color:'',
title:'🎉 发现疑似匹配的招领信息!',
desc:`系统发现一条疑似你丢失的【${matchedItem.name}】<br>发布于:${matchedItem.place}`,
btns:`<button class="go-mine" onclick="closeSuccess();openDetail(${matchedItem.id})">去查看匹配信息</button>
<button class="go-home" onclick="closeSuccess();go('home')">先返回首页</button>`
});
} else {
showPublishSuccess();
}
5.4 实现成果展示

当用户发布一条“钥匙”的寻物信息时,发布成功的弹窗会立刻变成“发现疑似匹配的招领信息”,并在界面上展示匹配到的物品名称和地点。用户只需点击弹窗中的“去查看匹配信息”按钮,系统就会直接打开该招领信息的详情页。
六.目录说明和使用说明
6.1 目录是如何组织的
整个项目的根目录下,index.html 作为整个单页应用的唯一入口,承载了所有的页面骨架与视图容器。css 文件夹下的 style.css 负责全局的样式定义、移动端 H5 布局以及交互动效。在核心的 js 文件夹中,将其划分为三个层级:data.js 作为数据层,统一管理默认数据以及 LocalStorage 的读写操作;utils.js 作为工具层,封装了 Toast 提示、时间格式化、弹窗及图片占位等通用函数;而 app.js 则是业务核心层,包含了页面路由分发、搜索逻辑、表单校验以及我们设计的智能匹配算法。根目录下还放置了 test.html 测试运行器和 assert.js 断言库,以及 img 文件夹中的图片素材,形成了一个完整工程。
6.2 测试人员如何运行网页
测试人员只需下载完整的项目文件夹,双击使用 Google Chrome 浏览器打开 index.html 即可体验完整的发布、浏览、搜索和状态流转流程。对于单元测试的运行,用 Chrome 打开根目录下的 test.html,随后按下 F12 打开控制台,就能立即看到所有自动化测试用例以绿色(通过)或红色(失败)的形式输出,无需任何命令行操作。
七.单元测试
7.1 测试工具及如何学习单元测试
在测试工具的选择与学习上,我们没有引入需要 Node.js 环境的 Jest 或 Mocha,而是自主设计了一套纯浏览器的轻量自动化测试方案。在学习单元测试的过程中,我们参考了邹欣老师关于单元测试和回归测试的博客,同时也查阅了主流前端测试框架的设计理念。老师要求“每个人都能很容易地运行它,团队一般是在每日构建中运行单元测试的”,如果强制成员配置复杂的 Node 环境会极大增加测试成本。因此我们基于浏览器原生能力,自主封装了 assert.js 断言库,并搭配 test.html 作为测试运行器。教程非常简单:测试人员只需用 Chrome 浏览器双击打开 test.html,按下 F12 打开控制台,就能立刻看到所有的测试用例以绿色(通过)或红色(失败)的形式输出报告。
7.2 单元测试代码
单元测试分为断言库和业务测试用例两部分。首先在 assert.js 中封装一个断言库,它包含了 equal(判断是否严格相等)和 true(判断是否为真)两个核心方法。如果断言成功,控制台会打印出醒目的绿色 ✅ [通过],失败则抛出红色的 ❌ [失败] 并附上具体的差异信息。
// 摘自 assert.js
const assert = {
equal(actual, expected, message) {
if (actual === expected) {
console.log(`✅ [通过] ${message}`);
} else {
console.error(`❌ [失败] ${message}。预期: ${expected}, 实际: ${actual}`);
}
},
true(value, message) {
if (value) {
console.log(`✅ [通过] ${message}`);
} else {
console.error(`❌ [失败] ${message}`);
}
}
};
在业务测试用例部分,我们挑选了最具有代表性的代码。首先是针对 renderSearch 搜索函数的测试。我们构造了关键词“耳机”,测试了全局数据过滤逻辑能否正确命中结果;同时构造了“洗衣机”这样的无效关键词,测试了返回空数组的边界情况。
// 摘自 test.html - TC-01 搜索逻辑测试
(function() {
const kw = '耳机';
let arr = DATA.filter(i=> i.name.includes(kw) || i.place.includes(kw) || i.desc.includes(kw));
assert.true(arr.length > 0, 'TC-01: 搜索“耳机”应返回结果');
})();
(function() {
const kw = '洗衣机';
let arr = DATA.filter(i=> i.name.includes(kw) || i.place.includes(kw) || i.desc.includes(kw));
assert.equal(arr.length, 0, 'TC-02: 搜索“洗衣机”应返回空');
})();
关于针对 resolvePost 状态更新函数的测试代码,在这段测试中,我们模拟了完整的标记解决流程,通过断言确保状态修改成功,并且数据确实从全局列表中移除,验证了这一系列数据操作的事务性。
// 摘自 test.html - TC-11 状态更新测试
(function() {
const testId = 999999;
DATA.unshift({id: testId, name: '测试数据', type: '寻物'});
MY_POSTS.unshift({id: testId, name: '测试数据', type: '寻物', status: 'showing'});
const p = MY_POSTS.find(x=>x.id==testId);
if(p) p.status = 'hidden';
const di = DATA.findIndex(x=>x.id==testId);
if(di >= 0) DATA.splice(di, 1);
const mp = MY_POSTS.find(x=>x.id==testId);
assert.equal(mp.status, 'hidden', 'TC-11: 标记已解决后状态应变为 hidden');
assert.equal(DATA.find(x=>x.id==testId), undefined, 'TC-11: 标记解决后主页数据应被移除');
MY_POSTS.splice(0,1);
})();
7.3 构造测试数据的思路/如何考虑各种情况/如何考虑将来测试人员的刁难
在构造测试数据的思路方面,我们综合考虑了正常流、边界值和异常流等多种情况。针对正常流程,我们测试了搜索能够正常命中结果。针对边界值,我们构造了 2.5MB 的文件对象,测试了图片上传函数能否精准拦截超过 2MB 的异常图片。针对异常流,我们测试了必填项为空时的校验逻辑,以及未上传图片时是否正确启用了灰色 SVG 占位图。而在面对将来测试人员可能的刁难时,我们提前预判了潜在的风险。比如,LocalStorage 取出来的 ID 是字符串,而 DOM 绑定传参可能是数字,我们特意在比对时使用弱相等来规避类型不一致导致点击失效的问题。同时,为了防止测试数据污染本地存储的正式数据,我们在每一个测试用例执行完毕后,都会自动 splice 清理掉测试时插入的假数据。这种种防御性编程的思考,确保了我们的应用在各种极端情况下依然能够保持整洁和稳定。
八.Github代码签入记录


代码签入遵循"先搭结构、再填内容、最后补测试和素材"的顺序。第1、2次提交完成工程骨架和视图层,让项目能先跑起来;第3至5次提交依次补齐数据层、工具层和业务层,页面从静态变为可交互;第6次提交加入自动化测试;第7次补充图片素材,项目至此完整可运行。
九.遇到的代码模块异常或结对困难及解决方法
发现点击首页卡片无法进入详情页,控制台报错 Cannot read properties of undefined (reading 'name')。尝试加 console.log 排查,发现传入的 id 是数字,而 LocalStorage 读取出的 id却变成了字符串。这是因为 JSON 序列化和反序列化导致了类型改变,使得原本的严格相等 ===匹配失败,返回了 undefined。
最初尝试在读取时遍历数据强转类型,但代码过于冗余。最终改为使用宽松相等 == 进行匹配,用 JS 的隐式类型转换机制解决了类型不一致的问题。
十.评价队友
10.1值得学习的地方
队友在逻辑设计与测试意识上的严谨程度值得我学习。他在数据层使用 LocalStorage 做持久化时,就已经提前考虑了容量限制、空数据兜底、类型不一致等各种边界情况,还专门封装了 saveData() 统一管理读写,避免我在渲染层到处调用。他设计的"智能匹配推送"功能尤为巧妙——发布寻物信息时自动匹配疑似招领,把传统的"人找信息"变成了"信息找人",体现了对真实使用场景的深入思考。
更值得一提的是他对单元测试的重视。在开发过程中他主动设计了 assert.js 断言库和 test.html 测试运行器,把纯浏览器环境下的自动化测试做得非常完整,覆盖了搜索命中、空结果、表单校验、图片超限、状态同步等 10 多个用例。正是因为有这套测试,我们在后期调整逻辑时能快速发现回归问题,比如加入智能匹配后主流程依然稳定。他在撰写博客时,把流程图、数据流图、关键代码段和测试思路整理得非常清楚,为最终交付节省了大量时间。
10.2需要改进的地方
在视觉细节上还有一点提升空间。比如他做的拨号弹窗和成功弹窗,功能逻辑都完整,但动画和配色相对简洁,与整体的视觉风格统一性略弱。我后来补了一些阴影和渐变,才让观感更好。
另外,在模块拆分上,虽然已经做了三层分离,但 app.js 承载的交互逻辑还是偏多,后期的如果继续迭代,可以考虑按页面进一步拆分(如 home.js、publish.js、mine.js),让文件职责更聚焦,也方便多人协作时减少冲突。
十一.部分功能演示
搜索页面展示:


发布页面演示

浙公网安备 33010602011771号