2026秋软件工程结对作业(第二次之程序实现)
🎒 2026秋软件工程第二次结对作业——校园失物招领(程序实现)
📌 校园里丢了校园卡、钥匙找不到人?捡到东西不知交给谁?我们做了一个集中式失物招领平台——发布、搜索、联系、标记状态一条龙,让失物早日回家 🏠。
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice/homework/16745 |
| 这个作业的目标 | 基于第一次结对作业的原型,完成"校园失物招领"核心功能(发布信息—浏览/搜索—查看详情—联系发布者—更新状态)的程序实现与单元测试 |
| 学号、姓名 | 102401304 向飞燕,102401302 陈果 |
| 结对同学博客链接 | https://www.cnblogs.com/chengguoo/p/23204473 |
| 本作业博客链接 | https://www.cnblogs.com/sakurax000/p/23230067 |
| GitHub 仓库 | https://github.com/Sakura-x000/102401302-102401304 |
✅ 已在班级群结对表与项目地址统计表中填写 GitHub 仓库地址。
👥 一、具体分工
| 成员 | 学号 | 主要负责 |
|---|---|---|
| 向飞燕 | 102401304 | 逻辑层(storage / validate / search / status)、单元测试、仓库与 PR 管理、README |
| 陈果 | 102401302 | 页面与交互(index.html / css / app.js)、走查测试、博客撰写、截图整理 |
两人共同参与:需求讨论、接口约定、联调走查、PSP 填写、博客互审。
⏱️ 二、PSP 表格
预估为开发前两人讨论估算,实际为开发后回填。
| PSP2.1 阶段 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning 计划 | 10 | 15 |
| Estimate 估计任务时间 | 10 | 10 |
| Development 开发 | ||
| Analysis 需求分析(含学习新技术) | 60 | 90 |
| Design Spec 生成设计文档 | 30 | 30 |
| Design Review 设计复审 | 20 | 20 |
| Coding Standard 代码规范 | 10 | 10 |
| Design 具体设计 | 40 | 45 |
| Coding 具体编码 | 240 | 300 |
| Code Review 代码复审 | 30 | 40 |
| Test 测试(自测、修改、提交) | 90 | 130 |
| Reporting 报告 | ||
| Test Report 测试报告 | 30 | 35 |
| Size Measurement 计算工作量 | 10 | 10 |
| Postmortem 事后总结 | 20 | 30 |
| 合计 | 600 | 765 |
💭 编码与测试两阶段实际耗时明显超出预估:编码超出约 1 小时,主要花在排查「浏览器原生 window.Storage 与自定义模块重名」上;测试超出约 40 分钟,主要花在 Node 环境下模拟 window / localStorage 上。事后总结阶段也比预估久,因为第一次结对协作需要磨合 PR 流程与接口约定。
💡 三、解题思路与设计实现说明
🔧 3.1 代码实现思路
我们选择 Web 网页 实现 🖥️,纯前端三件套(HTML + CSS + JavaScript),数据用浏览器 localStorage 持久化,不依赖后端。助教下载后用谷歌浏览器打开 index.html 即可运行 ✅。
整体采用分层解耦的结构 🏗️,逻辑层与页面层分离:
- 🗄️ 存储层
storage.js:localStorage 读写、信息增改、ownerId生成与持久化 - ✅ 校验层
validate.js:发布表单必填与格式校验,返回{ valid, errors } - 🔎 搜索层
search.js:关键词 + 多条件过滤,按发布时间倒序 - 🏷️ 状态层
status.js:统一"寻物/招领""进行中/已找到/已归还"文案 - 🎨 页面层
app.js:DOM 渲染与事件绑定,串联完整流程
核心数据流 🔄:app.js → 调用 Validate 校验 → 通过后 Storage.saveItem 存入 localStorage → 列表刷新时 Storage.getAllItems 取出 → 经 Search.filterItems 过滤排序 → 渲染到页面。
模块之间通过约定的接口(挂载在 window 上)通信,两人并行开发互不阻塞:一人写逻辑层,一人写页面层,页面层先用临时 mock 跑通,逻辑层完成后替换为真实模块。
💡 关于"用户身份"的设计说明:本项目为纯前端、无后端无登录,数据存于浏览器 localStorage,因此以浏览器为单位区分用户:
storage.js为每个浏览器生成一个ownerId,发布信息时携带该 id,据此判断是否为"我发布的"。需注意两点:① 同一浏览器内,示例数据已同时包含"本人发布"(带"我发布的"标记、可标记状态)与"他人发布"(无标记、无标记按钮)两类,可直接观察区分效果;② 不同浏览器或无痕窗口的 localStorage 相互独立、数据不互通,各自是独立的数据池。
🗺️ 3.2 关键实现流程图 / 数据流图
📋 发布信息流程图:

流程:填写表单 → 点击发布 → validateItem 校验 → 不通过(提示 errors,停留表单)/ 通过(saveItem 存入 → 关闭表单 → 刷新列表 → 成功提示)
🔀 数据流向图:

localStorage ⇄ storage.js ⇄ search.js ⇄ app.js(渲染);validate.js 负责入口校验;status.js 提供文案。
📝 3.3 重要代码片段与解释
① 校验模块——把"不完整发布"挡在入口(validate.js) 🚧
function validateItem(data) {
var errors = {};
data = data || {};
if (!data.title || !String(data.title).trim()) {
errors.title = '物品名称不能为空';
} else if (String(data.title).trim().length > 50) {
errors.title = '物品名称不能超过 50 字';
}
if (!data.type) {
errors.type = '请选择类型';
} else if (data.type !== 'lost' && data.type !== 'found') {
errors.type = '类型只能是寻物或招领';
}
// ... 地点、发布者、联系方式同理
return { valid: Object.keys(errors).length === 0, errors: errors };
}
解释:所有必填校验集中在一处,返回结构化的 errors,页面层据此提示,避免脏数据进入存储。
② 搜索模块——多条件组合过滤 + 时间倒序(search.js) 🔎
function filterItems(items, filters) {
filters = filters || {};
if (!Array.isArray(items)) return [];
var keyword = String(filters.keyword || '').trim().toLowerCase();
var result = items.filter(function (it) {
if (!it) return false;
if (filters.type && it.type !== filters.type) return false;
if (filters.category && it.category !== filters.category) return false;
if (filters.status && it.status !== filters.status) return false;
if (filters.ownerId && it.ownerId !== filters.ownerId) return false;
if (keyword) {
var haystack = [it.title, it.description, it.location, it.publisher, it.category]
.filter(Boolean).join(' ').toLowerCase();
if (haystack.indexOf(keyword) === -1) return false;
}
return true;
});
result.sort(function (a, b) { return (b.createdAt || 0) - (a.createdAt || 0); });
return result;
}
解释:关键词在标题/描述/地点/发布者/类别中模糊匹配;各筛选项可任意组合;最后按 createdAt 倒序。对 items 非数组做了防御,返回空数组而非报错。
③ 状态模块——一处定义、全页面一致的文案(status.js) 🏷️
function getStatusText(item) {
if (!item) return '';
if (item.status !== 'resolved') return '进行中';
return item.type === 'lost' ? '已找到' : '已归还';
}
解释:寻物结束叫"已找到"、招领结束叫"已归还",文案逻辑集中在这里,列表卡片和详情弹窗都调用它,避免各处硬编码不一致。

图中展示了"已找到"与"进行中"两种状态在卡片上的显示效果。
④ 标记状态更新(storage.js) ✏️
function updateItem(id, changes) {
if (!id || !changes || typeof changes !== 'object') return null;
var items = readItems();
for (var i = 0; i < items.length; i++) {
if (items[i].id === id) {
items[i] = Object.assign({}, items[i], changes);
writeItems(items);
return items[i];
}
}
return null; // 对不存在的 id 返回 null
}
解释:发布者在详情里点"标记为已找到/已归还",本质就是 updateItem(id, { status: 'resolved' }),更新后列表与详情同步刷新。
✨ 四、附加特点设计与展示
作业要求的功能不算附加特点,以下为我们额外做的人性化设计 💡。
特点一:📋 一键复制联系方式
- 创意与意义:失物招领的关键一步是"联系发布者"。手动选中复制微信号/手机号易出错,一键复制降低联系门槛。
- 实现思路:详情弹窗中放"📋 复制联系方式"按钮,调用剪贴板 API 复制并弹 Toast 提示;对非安全上下文(如 file:// 打开)做了降级方案。
- 代码(app.js):
$('btnCopy').addEventListener('click', () => {
const text = it.contact;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text)
.then(() => showToast('联系方式已复制'))
.catch(() => fallbackCopy(text));
} else {
fallbackCopy(text); // 非安全上下文降级方案
}
});
- 成果展示:


特点二:🔍 多维筛选 + 筛选状态提示
- 创意与意义:信息多了只靠关键词不够。加了类型、类别、状态、"我的发布"四种筛选,顶部紫色提示条实时显示"当前筛选:…"并带"清除筛选",让用户清楚自己的过滤状态。
- 实现思路:下拉框
change即触发刷新;本人发布信息带绿色"我发布的"标记。 - 代码(app.js):
function updateFilterHint() {
const parts = [];
if (f.keyword) parts.push('关键词「' + f.keyword + '」');
if (f.type) parts.push(f.type === 'lost' ? '寻物' : '招领');
if (f.category) parts.push(f.category);
if (f.status) parts.push(f.status === 'active' ? '进行中' : '已结束');
if (f.ownerId) parts.push('我的发布');
// 无筛选则隐藏提示条;否则显示"当前筛选:…" + 清除按钮
}
- 成果展示:


特点三:🌱 首次访问自动填充示例数据
- 创意与意义:新用户第一次打开若是空白页,无法直观感受功能。localStorage 为空时自动生成几条覆盖各种情况的示例,打开即有内容可看可点。
- 实现思路:初始化时判断列表是否为空,为空则写入示例(含寻物/招领、已结束状态、本人发布),用
length > 0判断避免重复填充。 - 代码(app.js):
function seedIfEmpty() {
if (Storage.getAllItems().length > 0) return; // 已有数据则不重复填充
const ownerId = Storage.getOwnerId();
const samples = [ /* 寻物/招领、含已解决状态、含本人发布的示例 */ ];
samples.forEach(s => Storage.saveItem(Object.assign({ id, status: 'active' }, s)));
}
- 成果展示:

特点四:📱 响应式适配
- 创意与意义:失物招领更多在手机上使用。页面在电脑和手机宽度下都能正常布局,卡片自适应。
- 实现思路:用媒体查询,窗口宽度小于 640px 时切换为单列纵向布局。
- 代码(style.css):
@media (max-width: 640px) {
.search-box { flex-direction: column; }
.filter-row { flex-direction: column; }
.filter-row select, .filter-row .btn { width: 100%; }
.list { grid-template-columns: 1fr; } /* 卡片由多列变单列 */
}
- 成果展示:


📂 五、目录说明与使用说明
5.1 目录组织 🗂️
102401302-102401304/
├── index.html # 页面结构(首页、发布表单、详情弹窗)
├── css/
│ └── style.css # 样式
├── js/
│ ├── storage.js # 数据存储(localStorage)
│ ├── validate.js # 发布信息校验
│ ├── search.js # 搜索与筛选
│ ├── status.js # 状态与类型文案
│ └── app.js # 页面渲染与交互(调用上述模块)
├── test/
│ └── unit.test.js # 单元测试(Mocha + Chai)
├── package.json # 依赖与测试脚本
├── .gitignore
└── README.md # 项目说明、目录与使用说明
逻辑层四个模块各自独立、挂在 window 上,页面层 app.js 只做渲染和调用,职责清晰、便于两人并行开发与测试。


上图为 README 中的目录说明。
5.2 如何运行 ▶️
- 将整个项目下载到本地;
- 用谷歌浏览器直接打开
index.html即可使用,无需安装任何环境、无需启动服务器 🚀; - (可选)运行单元测试:需安装 Node.js,在项目目录执行
npm install后npm test。 - 可用 F12 模拟手机页面,也可直接缩放网页宽度查看手机端展示效果。

🧪 六、单元测试
6.1 测试工具与学习、简易教程 📚
我们选用 Mocha(测试框架)+ Chai(断言库),理由是轻量、对原生 JS 友好、npm test 一条命令即可自动化运行。
学习过程:参考作业附录推荐的"廖雪峰的 JavaScript"与"Mocha 实例教程",先理解 describe / it / expect 的基本写法,再针对四个模块逐个设计用例。
简易教程(我们自己的小结) 📝:
npm init初始化项目,npm i -D mocha chai安装;package.json里设"scripts": { "test": "mocha" };- 测试文件用
import { expect } from 'chai',用describe('模块名', () => { it('用例名', () => { expect(...).to... }) })组织; - 命令行
npm test运行,看到✓和passing即通过。
6.2 测试代码与被测函数 💻
测试覆盖四个模块共 29 个用例,全部通过 ✅。以下为部分代码:
// 模拟浏览器环境(Node 里没有 window / localStorage)
global.window = {};
const storageMap = new Map();
global.localStorage = {
getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null),
setItem: (k, v) => storageMap.set(k, String(v)),
removeItem: (k) => storageMap.delete(k),
clear: () => storageMap.clear()
};
// 加载四个模块,它们会自动挂到 window 上
['storage.js', 'validate.js', 'search.js', 'status.js'].forEach((m) => {
eval(fs.readFileSync(path.join(rootDir, 'js', m), 'utf8'));
});
describe('Validate 模块', () => {
it('完全空的表单校验不通过', () => {
const r = Validate.validateItem({});
expect(r.valid).to.be.false;
expect(r.errors).to.have.keys(['title', 'type', 'location', 'publisher', 'contact']);
});
it('联系方式少于 3 字符时提示', () => {
const r = Validate.validateItem({
type: 'lost', title: '校园卡', location: '食堂',
publisher: '张三', contact: 'ab'
});
expect(r.valid).to.be.false;
expect(r.errors.contact).to.equal('联系方式至少 3 个字符');
});
});
describe('Search 模块', () => {
it('关键词无匹配时返回空数组', () => {
const r = Search.filterItems(sample, { keyword: '不存在的物品' });
expect(r).to.be.an('array').that.is.empty;
});
it('组合筛选:type + status', () => {
const r = Search.filterItems(sample, { type: 'lost', status: 'active' });
expect(r).to.have.lengthOf(1);
});
});
被测函数:Storage.getAllItems/saveItem/updateItem/getOwnerId、Validate.validateItem、Search.filterItems、Status.getStatusText/getTypeText。


终端
npm test通过画面。
6.3 测试数据构造思路与"刁难"考虑 🧠
采用白盒思路,针对每个函数的分支设计数据:
- ✅ 正常路径:合法表单校验通过、关键词命中、筛选返回正确条数;
- ⚠️ 空/边界:空 localStorage、空表单、联系方式过短、名称超长;
- 🧨 异常输入:
type传非法值、items传null/undefined、updateItem传不存在的 id、getStatusText传null——确保函数不崩溃、返回约定值; - 🔀 组合条件:类型+状态组合筛选,验证多条件交集正确;
- ↕️ 排序:验证无筛选时按
createdAt倒序。
考虑到将来测试人员可能传入各种奇怪输入,我们对每个模块的入口都做了类型判断与兜底(如 Array.isArray、data || {}),这也是测试能覆盖到的健壮性设计。
🌿 七、GitHub 代码签入记录
我们按功能划分提交,每完成一个模块 commit 一次,commit message 采用 类型: 描述 的规范格式。

完整记录可在 GitHub 仓库的 Commits 页面查看:
https://github.com/Sakura-x000/102401302-102401304/commits/main
签入记录(示例):
feat: 实现 localStorage 数据存储模块feat: 实现发布信息校验模块feat: 实现搜索与筛选模块feat: 实现状态与类型文案模块feat: 完成页面交互、示例数据与筛选提示test: 添加 29 个单元测试用例refactor: 移除临时 mock,改为直接引用逻辑层模块docs: 完善 README 目录说明与使用说明
🐛 八、遇到的代码模块异常或结对困难及解决方法
本次开发中我们遇到了两个比较典型的问题,一个出在代码层,一个出在协作流程层,分别记录如下。
问题一:浏览器原生 window.Storage 与自定义模块重名冲突 💥
- 问题描述:开发初期,页面层用
window.Storage || mock来判断逻辑层是否就位。但浏览器本身自带全局window.Storage(即 localStorage 的构造函数),导致判断误判、页面报getAllItems is not a function,整个列表渲染瘫痪。 - 做过哪些尝试:起初以为是模块没加载,反复检查
<script>引入顺序;后来在控制台打印window.Storage,才发现拿到的是浏览器原生对象,而不是我们自定义的模块。 - 是否解决:✅ 解决。改为检测具体方法是否存在(
window.Storage && typeof window.Storage.getAllItems === 'function'),而非直接判断对象真值;逻辑层就位后,用window.Storage = {...}直接覆盖原生对象。 - 有何收获:在浏览器全局命名空间挂载自定义模块时,要警惕与原生全局对象(如
Storage、History、Location)重名;判断"模块是否加载"应检测具体方法,而不是判断对象本身是否存在。
问题二:文本文件编码不一致导致中文乱码 🔤
- 问题描述:用 Windows 记事本编辑
index.html、js/app.js时,文件被默认保存为 GBK 编码,而 HTML 头部声明的是UTF-8。结果浏览器和 GitHub 上所有中文全部显示为鏍″洯...这类乱码。 - 做过哪些尝试:一开始怀疑是队友的代码写错了,在 CMD 里用
type命令直接读取文件内容,确认文件本身就是乱码;改用 VS Code 打开后,看右下角状态栏显示编码是GBK,通过Reopen with Encoding → UTF-8重新读取后,中文恢复正常。 - 是否解决:✅ 解决。所有文本文件统一用 VS Code 保存为 UTF-8,并在 VS Code 设置里把默认编码固定为 UTF-8,从源头杜绝。
- 有何收获:多人协作时,文件编码要提前统一。记事本等工具在不同系统下默认编码不同,极易在提交、拉取时引入乱码;使用现代编辑器(如 VS Code)并统一默认编码,能有效避免这类问题。
💬 小结:两个问题看似不相关,其实都指向同一个教训:协作开发中,"约定"比"各自能跑"更重要。接口要约定、编码要约定、命名规范也要约定。把这些前期约定做扎实,能省掉后面大量返工时间。
🤝 九、评价队友
向飞燕 评价 陈果(页面层)
值得学习的地方:
- 页面实现效率高。
index.html/css/style.css/app.js一次性写完并自测通过,PR 描述清晰(接口约定、数据字段、注意事项全都列出来了),我这边接逻辑层几乎零沟通成本。 - 细节考虑周到。
app.js里用「检测方法是否存在」而不是「判断对象真值」来判断逻辑层是否就位,避开了浏览器原生window.Storage的坑,这个设计帮我省了不少排查时间。 - 博客初稿写得很完整。 结构清晰、有 emoji 点缀、还有流程图配图,我只需补充少量内容。
需要改进的地方:
- 提交 PR 之前如果能在本地也跑一遍单元测试或做一次编码检查,会减少合并后的返工。
- 部分交互细节(如「标记为已找到」按钮出现的时机)可以在 PR 描述里写得更明确,方便我这边对照测试。
陈果 评价 向飞燕(逻辑层)
值得学习的地方:
搭档在逻辑层的接口设计上考虑得很周到——校验返回结构化的 { valid, errors }、搜索对异常输入(null、非法值)都做了兜底,这让我写页面层时几乎不用操心边界情况。而且她写的 29 个单元测试覆盖很全,commit message 也一直保持 feat / refactor / test 的规范格式,代码提交记录清晰合理,这些都很值得我学习。
需要改进的地方:
建议以后在模块刚写完时,可以更早同步一版可运行的逻辑文件,而不是等全部完成再一起给,这样页面层能更早接入真实模块做联调,减少最后阶段集中修改的压力。另外接口如果有调整,及时在群里同步一声,能避免两边对不上。(这一点我们这次配合其实已经不错,只是个可以更好的小建议。)
🎬 十、小结
本次结对作业,我们从原型走到了可运行的代码,完成了"发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态"的完整闭环,并通过 29 个单元测试与浏览器走查验证了功能 ✅。
我负责逻辑层(storage.js、validate.js、search.js、status.js)和单元测试,搭档负责页面与交互。最大的收获是体会到了 "分层解耦 + 接口先行"对两人并行开发的价值:我们先约定好四个模块挂在 window 上的接口,她就能用临时 mock 先把页面跑通,我写完逻辑层后直接替换,互不阻塞,最后一次联调就通过了。
过程中也踩了一个印象很深的坑:浏览器原生自带全局 window.Storage,和我们的模块重名,导致一开始列表整个渲染不出来。排查后才意识到"判断模块是否加载不能只看对象在不在,得看具体方法"。这个教训让我对浏览器全局命名空间有了更具体的认识。另外,这次把 29 个单元测试跑通、再用浏览器逐条走查,让我真切感受到"测试不是走形式,而是真能提前发现问题"。如果下次再做,我会更早地和搭档对齐数据字段,减少后期联调的小摩擦。
愿每一件失物都能早日回家 🏠。

浙公网安备 33010602011771号