软件工程第二次结对作业
2026秋软件工程第二次结对作业之程序实现 —— 校园失物招领平台
| 项目 | 内容 |
|---|---|
| 同学 1 | 马英慧 学号 102401604 |
| 同学 2 | 黄丽婷 学号 102401606 |
| 结对同学博客链接 | https://www.cnblogs.com/halttt/p/23237145 |
| 本作业博客链接 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16744 |
| 仓库地址 | https://github.com/halttt/102401604-102401606 |
| 呈现形式 | WEB 网页(PC 端),统一使用 Chrome 浏览器运行 |
一、结对信息与项目概况
本项目为「校园失物招领平台」,是在第一次结对作业(原型设计)基础上完成的程序实现。程序围绕「发布信息 — 浏览 / 搜索 — 查看详情 — 联系发布者 — 更新状态」这一基本流程,实现了寻物启事与招领启事的发布、集中浏览、关键词搜索、详情查看与状态维护。
- 技术栈:原生 HTML + CSS + JavaScript,数据保存在浏览器
localStorage中,不依赖任何后端服务器、不依赖任何第三方框架或外部资源。 - 运行方式:下载全部文件后,用 Chrome 双击打开
index.html即可运行,符合「下载所有文件后双击 html 就能展现预期结果」的要求。 - 功能范围:严格按作业规定,未涉及复杂后台、实名认证、即时聊天、地图定位,把力气集中在发布 / 搜索 / 详情 / 状态维护这条主链路上。
二、具体分工
| 成员 | 学号 | 主要分工 |
|---|---|---|
| 马英慧 | 102401604 | 核心 JS 交互逻辑(localStorage 数据读写、搜索与分类筛选、详情渲染、状态修改、一键复制、我的发布);单元测试用例的设计与实现;GitHub 协作流程(fork → 提交 → Pull Request → 代码复审合并);README 与目录 / 使用说明 |
| 队友 | 102401606 | 页面布局与 HTML 骨架搭建;CSS 样式设计与美化(卡片、配色、圆角、间距、响应式);整体视觉风格把控;协助功能联调与截图整理 |
两人在编码过程中互相审阅代码、共同排查问题:一方写完一个功能就提交,另一方在 Pull Request 里做 code review,发现问题及时讨论修改,保证「每人都有清晰、合理的签入记录」。
三、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 25 |
| · Estimate | 估计这个任务需要多少时间 | 20 | 25 |
| Development | 开发 | 480 | 620 |
| · Analysis | 需求分析(包括学习新技术) | 60 | 50 |
| · Design Spec | 生成设计文档 | 30 | 25 |
| · Design Review | 设计复审 | 20 | 15 |
| · Coding Standard | 代码规范 | 15 | 10 |
| · Design | 具体设计 | 45 | 40 |
| · Coding | 具体编码 | 240 | 365 |
| · Code Review | 代码复审 | 30 | 45 |
| · Test | 测试(自我测试、修改代码、提交修改) | 40 | 70 |
| Reporting | 报告 | 95 | 115 |
| · Test Report | 测试报告 | 30 | 40 |
| · Size Measurement | 计算工作量 | 15 | 15 |
| · Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 50 | 60 |
| 合计 | 595 | 760 |
说明:实际编码耗时明显超出预估(365 分钟 vs 预估 240 分钟),主要原因是多人协作时出现了「同一文件被覆盖、内联脚本与外部脚本变量冲突」等返工(详见第九节);测试环节也因反复走查「发布信息不完整、搜索无结果」等异常路径而超时。这些偏差被记录在 PSP 中,用于改进下一次任务的估计。
四、解题思路描述与设计实现说明
4.1 整体思路
第一次结对作业已经确定了产品方案与页面结构,本次的任务是把它「变成能跑起来的代码」。因此整体思路是:先把数据层定死,再让四个页面对接同一份数据。
由于作业要求「下载文件后用 Chrome 直接打开 html 就能运行」,我们放弃了后端方案,改用浏览器 localStorage 充当数据存储:所有信息以一个 JSON 数组的形式存在 lostFoundList 这个键里,页面之间不通过网络通信,读写统一经由 js/main.js 中的函数完成。这样既满足了「双击即可运行」,又天然实现了「发布后其他页面能看到」的效果。
4.2 代码实现思路(文字描述)
程序共 4 个页面,共用一份 js/main.js 与一份 css/style.css:
-
数据层(
initData/getList/saveList)
首次打开时,initData()会向localStorage写入 3 条示例数据(校园卡、钥匙、AirPods),保证页面一打开就有内容可看;getList()统一负责「读取 → 解析 JSON」,saveList()统一负责「序列化 → 写回」,所有模块都不直接操作localStorage,避免读写风格不统一。 -
首页浏览 / 搜索 / 筛选(
renderList/searchList/switchTab)
页面加载后调用renderList(),遍历数据动态生成卡片;搜索框输入关键词后由searchList()按「物品名称」模糊匹配;顶部三个标签「全部 / 寻物启事 / 招领启事」由switchTab()切换。为避免「搜索」和「筛选」互相打架,用全局变量currentTab记录当前所处的标签页,两个操作都先按currentTab过滤、再按关键词过滤,保证组合条件正确。 -
发布信息(
addItem)
表单提交时先做必填校验(称呼、标题、物品名称、联系方式不能为空),通过后把新记录push进数组并写回。新记录的id用Date.now()生成,天然不重复;同时把用户填的称呼写入myName,实现「下次发布不用重填」。 -
详情查看(
loadDetail/copyContact/changeStatus)
首页卡片点击后跳转detail.html?id=xx,详情页从 URL 的 query 中取出id,在数据里查找到对应记录并渲染。发布者可通过「📋 一键复制」把联系方式复制到剪贴板,也可以点「✓ 已找到 / ✓ 已归还」修改状态,changeStatus()写回后立即重新渲染,状态实时更新。 -
我的发布(
loadMyPosts)
按「我的称呼」过滤出本人发布的所有信息,方便统一管理自己发过的内容。
4.3 关键实现的流程图与数据流图
下图为本程序的核心功能流程图(用户视角的操作闭环):

下图为本程序的数据流图(页面、核心函数与数据存储之间的关系):

4.4 关键代码片段及解释
(1)统一的数据读写层——把「读 / 写」收口到两个函数,四个页面复用同一套逻辑,避免格式不一致。
// ========== 初始化数据 ==========
function initData(){
if(!localStorage.getItem("lostFoundList")){
const initList = [
{ id:1, type:"lost", author:"张三", title:"校园卡丢失",
itemName:"校园卡", description:"周三下午一食堂三楼丢失,卡面有照片",
contact:"13800138000", status:"pending", createTime:"2026-09-28" },
// ...(其余两条示例数据)
];
localStorage.setItem("lostFoundList", JSON.stringify(initList));
}
}
// ========== 数据读写 ==========
function getList(){
initData();
return JSON.parse(localStorage.getItem("lostFoundList"));
}
function saveList(list){
localStorage.setItem("lostFoundList", JSON.stringify(list));
}
(2)搜索与筛选的组合逻辑——用 currentTab 记录当前标签,保证「先筛选、再搜关键词」,两个功能同时生效不冲突。
let currentTab = 'all';
// 标签切换
function switchTab(tab, btn){
currentTab = tab;
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
btn.classList.add('active');
let data = getList();
if(tab !== 'all') data = data.filter(i=>i.type===tab);
renderList(data);
}
// 搜索
function searchList(){
const kw = document.getElementById("searchInput").value.trim().toLowerCase();
let data = getList();
if(currentTab !== 'all') data = data.filter(i=>i.type===currentTab);
if(kw) data = data.filter(i=>i.itemName.toLowerCase().includes(kw));
renderList(data);
}
(3)状态维护——「物品找回 / 归还」后由发布者标记,写回后立即重渲染,保证其他页面看到的是最新状态。
function changeStatus(id, newStatus){
const list = getList();
const target = list.find(x=>x.id===id);
if(!target) return;
target.status = newStatus;
saveList(list);
alert("状态修改成功!");
loadDetail();
}
(4)一键复制联系方式——减少手动抄写手机号 / 微信号的麻烦。
function copyContact(text){
navigator.clipboard.writeText(text).then(()=>alert("联系方式已复制!"));
}
4.5 附加特点设计与展示
除了作业要求的基本功能,我们还做了一些方便用户的小设计:
| # | 附加特点 | 说明与意义 |
|---|---|---|
| 1 | 分类标签筛选 | 顶部「全部 / 寻物启事 / 招领启事」一键切换,只看自己关心的类型 |
| 2 | 一键复制联系方式 | 详情页把手机号 / QQ / 微信一键复制到剪贴板,避免手动输入出错 |
| 3 | 称呼自动记忆 | 发布时填过的称呼存入 myName,下次打开发布页、我的发布页自动带出 |
| 4 | 「我的发布」聚合页 | 按称呼筛选出本人发布过的全部信息,方便统一管理与修改状态 |
| 5 | 状态可视化管理 | 每条信息有「待处理 / 已找到 / 已归还」三种状态且配色区分,减少重复询问和无效联系 |
| 6 | 空状态友好提示 | 无搜索结果、未发布过信息、找不到对应记录时都给出明确文字提示,而不是空白页 |
| 7 | 寻物 / 招领配色区分 | 卡片左侧色条按类型区分(寻物红、招领绿),一眼分辨信息类型 |
| 8 | 内置单元测试入口 | 测试代码随程序发布,控制台输入 runAllTest() 即可一键自测,无需安装任何环境 |
五、单元测试
5.1 测试工具与学习过程(附简易教程)
选用工具:本项目采用原生 JavaScript 断言 + 浏览器控制台输出的轻量测试方案,测试代码直接写在 js/main.js 末尾,通过控制台执行 runAllTest() 触发。
为什么不用 Mocha / Jest?
我们学习了廖雪峰的 JavaScript 教程、阮一峰的博客以及《测试框架 Mocha 实例教程》等资料,理解了单元测试「断言 + 用例组织 + 结果汇总」的基本模型。但考虑到本作业要求「下载文件后用 Chrome 打开 html 就能运行,助教参照目录说明即可复现」,如果再引入 Node、npm、Mocha,会让评分人员多出「装环境」的门槛。因此我们把 Mocha 的核心思想(断言、用例、通过 / 失败计数)用最朴素的原生 JS 实现出来,做到零依赖、双击即跑。
简易教程(自己怎么写一个控制台单元测试):
- 写一个「断言函数」:接收实际值与期望值,相等就
pass++,否则fail++并打印失败信息; - 按「一个用例一件事」组织代码,每个用例只验证一个行为,并给它编号(T1、T2…);
- 用例之间尽量互不影响,测试产生的脏数据要在结束时清理掉;
- 最后统一打印
通过 X,失败 Y,一眼看出结果。
5.2 单元测试代码与说明
以下为项目中的单元测试代码(节选,完整代码见 js/main.js 末尾的 runAllTest()):
function runAllTest(){
console.log("===== 开始单元测试 =====");
let pass=0, fail=0;
const oldLen = getList().length;
// T1 正常新增一条记录
let arr = getList();
arr.push({id:99999,type:"lost",author:"测试",title:"测试水杯",
itemName:"水杯",description:"测试",contact:"111",
status:"pending",createTime:getNowDate()});
saveList(arr);
getList().length === oldLen+1 ? (console.log("T1 通过:新增记录"),pass++) : (console.log("T1 失败"),fail++);
// T2 搜索关键词命中物品名称
getList().some(i=>i.itemName.includes("水杯")) ? (console.log("T2 通过:搜索命中"),pass++) : (console.log("T2 失败"),fail++);
// T3 搜索不存在的关键词,返回空
!getList().some(i=>i.itemName.includes("火箭123")) ? (console.log("T3 通过:无结果返回空数组"),pass++) : (console.log("T3 失败"),fail++);
// T4 修改状态为已找到
let l = getList(); let it = l.find(x=>x.id===99999);
it.status = "found"; saveList(l);
getList().find(x=>x.id===99999).status==="found" ? (console.log("T4 通过:状态改为已找到"),pass++) : (console.log("T4 失败"),fail++);
// T5 修改状态为已归还
// ...(同 T4,改为 returned)
// T6 查询不存在的ID,返回空
// T7 物品名称为空时拦截提交
// T8 按类型筛选(寻物 / 招领)
// T9 我的发布按称呼筛选
// T10 搜索大小写不敏感
console.log(`===== 测试结束:通过 ${pass},失败 ${fail} =====`);
saveList(getList().filter(i=>i.id!==99999)); // 清理测试脏数据
}
各用例测试的函数与验证目标:
| 用例 | 测试的函数 / 行为 | 验证目标 |
|---|---|---|
| T1 | saveList() / getList() |
新增一条记录后总数 +1 |
| T2 | 搜索逻辑(itemName 匹配) |
关键词能命中物品名称 |
| T3 | 搜索逻辑 | 不存在的关键词返回空数组,不报错 |
| T4 | changeStatus('found') |
状态能正确改为「已找到」 |
| T5 | changeStatus('returned') |
状态能正确改为「已归还」 |
| T6 | 详情查找(find) |
不存在的 ID 返回空,页面显示「找不到这条信息」 |
| T7 | 表单校验 | 物品名称为空时被拦截,不允许提交 |
| T8 | switchTab() 类型筛选 |
按「寻物 / 招领」筛选结果正确 |
| T9 | loadMyPosts() |
「我的发布」按称呼筛选结果正确 |
| T10 | 搜索逻辑 | 搜索不区分大小写(toLowerCase) |
5.3 构造测试数据的思路
我们按「正常值 → 边界值 → 异常值」三类来设计测试数据,尽量覆盖各种情况:
- 正常值:新增一条合法记录(T1)、搜索一个肯定存在的关键词(T2)、把状态正常改成已找到 / 已归还(T4、T5);
- 边界值 / 空值:搜索时关键词为空、物品名称为空(T7)、「我的发布」查一个没发过东西的称呼;
- 异常值:搜索一个绝对不存在的关键词(T3)、查询一个不存在的 ID(T6);
- 组合与细节:类型筛选与关键词搜索的组合(T8、T9)、大小写不一致的搜索(T10)。
如何应对测试人员的「刁难」? 我们重点想了两类容易被挑刺的场景:一是空数据 / 空输入(比如搜索框什么都不填、直接点搜索),程序应返回全部数据而不是报错;二是无效操作(比如手动改 URL 里的 ?id= 为一个不存在的值),程序应给出「找不到这条信息」的友好提示而不是白屏。测试用例 T3、T6、T7 就是冲着这两类场景设计的。
5.4 测试运行结果
打开 Chrome,按 F12 调出控制台,输入 runAllTest() 回车,结果如下(10 个用例全部通过):

六、目录说明与使用说明
6.1 目录结构
102401604-102401606/
├─ index.html # 首页:浏览全部失物招领、关键词搜索、分类筛选
├─ publish.html # 发布页:填写寻物 / 招领表单
├─ detail.html # 详情页:查看详细信息、一键复制联系方式、修改状态
├─ myposts.html # 我的发布:按称呼查询本人发布过的信息
├─ css/
│ └─ style.css # 全部页面共用的样式
├─ js/
│ └─ main.js # 全部业务逻辑:本地存储读写、搜索筛选、状态修改、单元测试
└─ README.md # 目录说明与使用说明
目录组织的原则是「按职责分层」:HTML 只负责页面结构,样式集中在 css/,交互逻辑集中在 js/,四个页面共用同一份 CSS 与 JS,避免重复代码。
6.2 运行与使用说明
运行环境:Windows / macOS 均可,浏览器统一使用 Google Chrome(防止不同浏览器效果不一致)。
- 从 GitHub 仓库下载整个项目(
Code → Download ZIP)并解压; - 双击打开
index.html(或用 Chrome 拖入); - 即可看到首页的示例失物招领信息。
基本操作:
- 浏览 / 搜索:首页直接浏览所有卡片;在顶部搜索框输入物品名称(如「校园卡」)点搜索;用「全部 / 寻物启事 / 招领启事」标签筛选。
- 发布信息:点右上角「发布信息」,填写信息类型、称呼、标题、物品名称、描述、联系方式,点「立即发布」。
- 查看详情 / 联系:点卡片上的「查看详情」,可看到完整描述与联系方式,点「📋 一键复制」复制联系方式。
- 更新状态:在详情页点「✓ 已找到」或「✓ 已归还」,状态实时更新。
- 我的发布:点「我的发布」,输入发布时填的称呼,即可查看本人发布过的全部信息。
⚠️ 数据保存在浏览器本地存储(localStorage),清理浏览器缓存会导致已发布的数据丢失。
6.3 实现成果展示
① 首页(浏览、搜索、分类筛选)

② 发布信息页

③ 物品详情页(一键复制联系方式、状态维护)

④ 我的发布页

七、结对感受与总结
在本次结对编程作业中,我们基于上一轮的原型设计,把校园失物招领的想法实现为可本地直接运行的网页程序。开发使用GitHub仓库协同,一人负责页面布局、HTML骨架和CSS样式美化,另一位负责JS交互逻辑的编写,遇到问题两个人一起讨论调试。
真正动手编码之后,我们感受到原型图和实际代码之间存在不小差距。很多在原型上看着简单的交互,写代码时会遇到各种细节难题,例如本地存储更新后页面如何刷新、弹窗开关控制、剪贴板接口在浏览器中的权限限制等,这些细节在原型设计阶段很难考虑周全。结对编程的好处就是两人可以互相审阅代码,快速发现对方忽略的问题,减少程序缺陷。
同时我们也意识到这个纯前端实现方案的短板:所有数据保存在浏览器本地存储里,如果用户清理浏览器缓存,所有发布信息都会消失。如果后续继续迭代项目,可以增加后端数据库,实现多设备数据互通,增加账号登录模块,进一步完善整个系统。
这次作业完整走完从原型到代码实现、测试、文档编写的流程,锻炼了前端编码、GitHub团队协作、软件测试和项目文档撰写能力。结对编程不是简单拆分任务各自写代码,互相评审、一起讨论方案、协同排查bug,能够更早发现设计上的不足,提升最终软件的质量。
八、Github代码签入记录
下图为本项目GitHub提交记录,每次提交都编写规范commit信息,清晰记录每次改动内容。

九、遇到的代码模块异常或结对困难及解决方法
1. 问题一
- 问题描述:短时间连续多次点击物品卡片,会同时弹出多层详情弹窗,页面交互错乱。
- 做过哪些尝试:一开始只写弹窗隐藏逻辑,快速点击时事件来不及终止;后面增加全局弹窗状态标记,记录弹窗打开/关闭状态。
- 是否解决:✅ 已解决
- 有何收获:弹窗交互必须增加状态锁,限制重复触发;前端开发要考虑用户快速连续点击这类非常规操作。
2. 问题二
- 问题描述:直接双击本地HTML文件打开网页时,浏览器安全策略会限制剪贴板接口,一键复制联系方式的功能无法正常触发。
- 做过哪些尝试:查阅官方文档了解本地file协议的权限限制;在复制代码增加try-catch捕获异常,当复制失败时弹出提示给用户。
- 是否解决:✅ 增加了降级方案,复制失败会提示用户手动复制文本
- 有何收获:浏览器不同访问方式权限不一样,开发前端功能要提前考虑环境差异,增加异常兜底逻辑,避免功能直接失效。
3. 问题三
- 问题描述:首页打开后一直停留在「加载中…」,失物招领列表完全不渲染。按
F12打开控制台,报出一条解析期错误:Uncaught SyntaxError: Identifier 'currentTab' has already been declared (at index.html:43:13)。 - 做过哪些尝试:先怀疑数据没写进本地存储,检查后发现
localStorage里数据正常;再把错误定位到具体行——index.html第 42 行引入的js/main.js里已经声明了let currentTab,而第 43~46 行的内联脚本又重复声明了一次同名变量。JavaScript 顶层作用域不允许用let重复声明同一标识符,于是整段内联脚本在解析阶段就抛错,window.onload里的renderList()根本没机会执行,列表于是一直停在「加载中…」。 - 是否解决:✅ 已解决——删除内联脚本中重复的
let currentTab,只保留window.onload = function(){ renderList(); };;并用 Node 复现浏览器全局作用域,做了修复前后的对照验证(修复前稳定复现该SyntaxError,修复后无冲突、首页正常渲染出 3 张卡片)。 - 有何收获:内联脚本与外部脚本共享同一个顶层作用域,变量名会互相冲突,变量命名要避免「两处都声明」;排查「页面点了没反应 / 白屏」这类问题时,优先看控制台的第一条报错并定位到具体行,比盲目改代码高效得多。这次问题也暴露出多人协作中同一文件被反复覆盖的风险,提醒我们要小步提交、及时同步彼此改动。
修复前的报错现场:

修复前首页一直停在「加载中…」:

十、评价队友
值得学习的地方
队友在页面设计与CSS样式实现方面很有想法,对界面的美观度、细节排版比较敏感,愿意反复调整卡片配色、圆角、间距,让网页看起来整洁舒服。写出来的HTML和CSS代码结构清晰,命名规范。在GitHub协作过程中,每次提交代码都会写清楚commit注释,方便我们回溯每次改动。遇到项目难题时,不会搁置问题,会主动和我沟通想法,一起商量可行的解决方案。在文档撰写阶段,主动收集项目截图,配合梳理博客内容,分工上十分配合,结对沟通效率很高。
需要改进的地方
在JavaScript逻辑开发时,前期思考更多是正常流程下的功能实现,对于各种边界、异常场景考虑偏少,很多问题只有在实际测试的时候才会暴露出来。另外编码习惯上,有时候会一次性写完一整块功能代码再提交,没有拆分成更小的增量分步提交。一旦出现bug,需要在一大段代码里定位错误,增加了调试和代码复审的压力。后续开发可以尝试小步迭代,完成一个微小功能就提交一次,便于结对互相审查,问题也可以尽早发现,避免大量缺陷集中到最后才暴露。
浙公网安备 33010602011771号