软工第二次结对作业
2026秋软件工程个人作业(第四次)——校园失物招领(结对编程)
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 2026秋软件工程与软件工程实践 |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice/homework/16745 |
| 结对成员 | 学号102401635(姓名 尼玛顿珠)、学号 102401623(姓名 杜佳伟) |
| 本作业博客 | https://www.cnblogs.com/kwndl/p/23240220 |
| 结对同学博客 | https://i.cnblogs.com/posts/edit;postId=23241986 |
| GitHub 仓库 | https://github.com/kwndl108/102401623-102401635 |
一、具体分工
- 【尼玛顿珠】:需求梳理与整体架构设计、数据层(store.js)与工具层(utils.js)实现、单元测试编写、博客撰写;
- 【杜佳伟】:视图层(app.js)页面渲染与交互实现、界面样式(style.css)、README 编写、GitHub 仓库搭建与合并。
两人先共同讨论功能划分与数据结构,再各自负责独立模块,最后互相 review 代码并一起联调。
二、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | ||
| Estimate | 估计这个任务需要多少时间 | 60 | 50 |
| Development | 开发 | ||
| Analysis | 需求分析(包括学习新技术) | 120 | 110 |
| Design Spec | 生成设计文档 | 60 | 55 |
| Design Review | 设计复审(和同伴一起审核设计文档) | 30 | 25 |
| Coding Standard | 代码规范(制定合适的规范) | 30 | 20 |
| Design | 具体设计(页面结构、数据结构、函数划分) | 90 | 85 |
| Coding | 具体编码 | 300 | 290 |
| Code Review | 代码复审 | 60 | 45 |
| Test | 测试(自我测试、修改代码、提交修改) | 120 | 150 |
| Reporting | 报告 | ||
| Test Report | 测试报告(单元测试用例设计与运行) | 60 | 45 |
| Size Measurement | 计算工作量 | 15 | 10 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 30 | 25 |
| 合计 | 975 | 910 |
三、解题思路描述与设计实现说明
3.1 代码实现思路(文字描述)
本次在第一次结对作业原型的基础上,用原生 HTML + CSS + JavaScript 实现了完整的 WEB 版「校园失物招领」,不依赖任何框架和构建工具,下载后用谷歌浏览器直接打开 index.html 即可运行。
整体按「数据层 → 工具层 → 视图层」三层组织:
- 数据层(store.js):物品数据结构为
{id, type, name, category, place, placeDetail, time, desc, images, contact, status, views, contacts, createdAt, publisher}。所有数据保存在localStorage(首次运行自动写入 6 条种子数据),提供add / update / remove / getById / getMine等 CRUD 接口,update只修改传入的字段,方便状态切换。 - 工具层(utils.js):把搜索过滤、表单校验、时间格式化、状态文案、一键复制、图片压缩等纯逻辑抽成独立函数,不依赖任何 DOM,因此可以脱离页面直接用单元测试框架测试。
- 视图层(app.js):用 hash 路由实现五个页面(首页/搜索/发布/详情/我的)的单页切换,页面渲染采用模板字符串,事件统一用
addEventListener绑定。
核心流程闭环为:发布信息 → 首页/搜索列表展示 → 查看详情 → 联系发布者 → 在「我的」标记已找到/已归还 → 状态全局更新。
3.2 关键实现的数据流图
![校园失物招领小程序 · 程序数据流图]
数据流说明:
- 发布流程:发布页收集表单 →
validatePublish校验必填项 → 通过后Store.add写入localStorage→ 首页与搜索列表即时刷新; - 浏览/搜索流程:首页列表或搜索页关键词 →
filterItems组合过滤(关键词+类型+类别+地点+状态)→ 渲染列表 → 点击卡片进入详情(浏览量 +1); - 联系流程:详情页点击「联系发布者」→ 联系人次数 +1 → 显示联系方式 →
copyText一键复制到剪贴板; - 状态维护流程:「我的发布」点击「标记已找到/已归还」→
Store.update(status)写入存储 → 全站该物品状态文案同步变化,并支持重新开放。
3.3 重要代码片段
① 搜索过滤函数 filterItems(核心业务逻辑)
filterItems: function (items, cond) {
cond = cond || {};
var kw = Utils.normalizeKeyword(cond.keyword);
return items.filter(function (it) {
if (cond.type && it.type !== cond.type) return false;
if (cond.category && it.category !== cond.category) return false;
if (cond.place && it.place !== cond.place) return false;
if (cond.status) {
if (cond.status === 'open' && it.status !== 'open') return false;
if (cond.status === 'closed' && it.status !== 'closed') return false;
}
if (kw) {
var hay = Utils.normalizeKeyword(
[it.name, it.desc, it.place, it.placeDetail, it.category].join(' ')
);
if (hay.indexOf(kw) === -1) return false;
}
return true;
});
}
设计要点:这是一个纯函数——不修改原数组、不依赖页面状态,输入输出完全确定,所以既能被页面调用,也能被单元测试直接验证。关键词同时匹配名称、描述、地点、类别,并做了大小写与首尾空格归一化,用户搜索" 校园卡 "也能命中。
② 发布表单校验 validatePublish(返回结构化错误)
validatePublish: function (form) {
var errors = {};
if (!form.name || !String(form.name).trim()) errors.name = '请填写物品名称';
if (!form.category) errors.category = '请选择物品类别';
if (!form.place) errors.place = '请选择丢失/拾取地点';
if (!form.time || !String(form.time).trim()) errors.time = '请填写丢失/拾取时间';
if (!form.contact || !String(form.contact).trim()) errors.contact = '请填写联系方式';
if (String(form.contact || '').trim() && String(form.contact).trim().length < 5) {
errors.contact = '联系方式过短,请填写完整的微信号或手机号';
}
if (form.images && form.images.length > 3) errors.images = '最多上传 3 张图片';
return Object.keys(errors).length ? { ok: false, errors: errors } : { ok: true, errors: {} };
}
设计要点:校验结果不是简单返回"对/错",而是返回字段级错误对象,视图层拿到后可以精确地在对应输入框下标红提示,用户体验比弹窗提示更好。
四、附加特点设计与展示
4.1 创意独到之处与意义
- 纯前端 + localStorage 方案:无需服务器即可完整演示"发布—浏览—搜索—联系—状态更新"闭环,刷新不丢数据,方便助教测试;后续可平滑替换为后端接口。
- 图片上传自动压缩:选择手机照片后 canvas 压缩成 base64 再存储,不占服务器空间,也避免 localStorage 容量超限。
- 计数体系:每条信息记录浏览数与联系数,帮助发布者判断信息热度,也给数据统计页提供真实数据来源。
4.2 实现思路
把「容易测的逻辑」和「难测的 DOM」分离:搜索、校验、格式化等纯函数全部放进 utils.js,页面只负责"收集输入 → 调函数 → 渲染结果"。这样单元测试无需启动浏览器交互即可覆盖核心逻辑,也让代码结构更清晰。
4.3 重要代码片段
一键复制联系方式(带降级方案)
copyText: function (text) {
return new Promise(function (resolve) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function () { resolve(true); }, function () {
legacyCopy(text, resolve); // 降级到 execCommand
});
} else {
legacyCopy(text, resolve);
}
});
}
要点:navigator.clipboard 只在 HTTPS/本地安全上下文中可用,file:// 打开时部分浏览器不可用,因此增加了 execCommand('copy') 的隐藏 textarea 兜底方案,保证本地双击打开也能一键复制。
4.4 实现成果展示
程序界面(谷歌浏览器打开 index.html 实测截图):
![]() 首页 |
![]() 搜索页 |
![]() 详情页 |
![]() 发布页 |
![]() 我的发布 |
![]() 单元测试 |
五、目录说明和使用说明
5.1 目录是如何组织的
campus-lost-found/
├── index.html # 程序入口(谷歌浏览器直接打开)
├── README.md # 目录说明 + 使用说明
├── css/
│ └── style.css # 全局样式(绿色主题、卡片布局、响应式)
├── js/
│ ├── store.js # 数据层:localStorage 读写、CRUD、计数、种子数据
│ ├── utils.js # 工具层:搜索过滤、校验、格式化、复制、图片压缩(纯函数)
│ └── app.js # 视图层:hash 路由、5 个页面渲染、弹窗/toast、事件绑定
├── images/ # 物品示例图片(来自第一次作业原型)
└── test/
├── test.html # 单元测试页面(打开即运行)
└── lib/ # 本地化测试框架 mocha.js / mocha.css / chai.min.js
依赖关系:index.html 按 store.js → utils.js → app.js 顺序加载脚本;测试页只加载 store.js 和 utils.js(纯逻辑,不依赖页面)。
5.2 测试人员如何运行
- 从 GitHub 克隆/下载项目全部文件,保持目录结构不变;
- 使用谷歌浏览器(Chrome) 双击打开
index.html即可使用,无需安装任何环境、无需联网; - 运行单元测试:用谷歌浏览器打开
test/test.html,页面自动运行全部测试用例(Mocha/Chai 已放在本地test/lib/,离线可跑); - 「我的」页面提供"重置数据"按钮,可一键恢复 6 条示例数据,方便反复测试发布/编辑/标记状态等操作。
六、单元测试
6.1 选用的测试工具与简易教程
选用 Mocha + Chai(浏览器版)。Mocha 是 JS 最流行的测试框架,提供 describe / it 描述式用例组织;Chai 提供 assert 断言库。学习途径:Mocha 官方文档 + 阮一峰的 Mocha 入门教程 + 廖雪峰 JavaScript 教程的测试章节。
简易上手步骤:
- 下载
mocha.js、mocha.css、chai.min.js放入test/lib/(本地化,离线可用); - 在
test.html中按顺序引入mocha.css → mocha.js → chai.min.js,并调用mocha.setup('bdd'); - 引入被测的
store.js、utils.js; - 写用例:
describe('分组', function(){ it('用例描述', function(){ assert.equal(实际值, 期望值); }); }); - 页面底部调用
mocha.run(),浏览器打开即出结果。
6.2 部分单元测试代码与说明
测试对象:搜索过滤、发布校验、状态文案、时间格式化、数据层 CRUD 等纯逻辑。
// 搜索过滤测试使用独立构造的 fixture 数据,
// 不依赖 localStorage 的真实状态(避免手动操作过数据后导致用例失败)
var FIX = [
{ id: 'f1', type: 'lost', name: '校园卡', category: '证件卡片', place: '图书馆', desc: '蓝色校园卡', status: 'open' },
{ id: 'f2', type: 'found', name: '白色无线耳机', category: '数码电子', place: '运动场', desc: '白色 AirPods 风格无线耳机', status: 'open' },
{ id: 'f3', type: 'lost', name: '黑色折叠雨伞', category: '服饰箱包', place: '图书馆', desc: '自动伞', status: 'open' },
{ id: 'f4', type: 'found', name: '蓝色保温杯', category: '杯具水壶', place: '第二食堂', desc: '贴纸款', status: 'closed' }
];
describe('Utils.filterItems(搜索过滤)', function () {
it('按物品名称关键词过滤:搜索"校园卡"能命中名称中含校园卡的信息', function () {
var res = Utils.filterItems(FIX, { keyword: '校园卡' });
assert.ok(res.length >= 1);
res.forEach(function (it) { assert.match(it.name, /校园卡/); });
});
it('关键词忽略大小写与首尾空格:搜索" AirPods"等价于"airpods"', function () {
var a = Utils.filterItems(FIX, { keyword: ' AirPods ' });
var b = Utils.filterItems(FIX, { keyword: 'airpods' });
assert.deepEqual(a.map(function (x) { return x.id; }), b.map(function (x) { return x.id; }));
});
it('组合条件:type=lost 且 关键词=雨伞', function () {
var res = Utils.filterItems(FIX, { type: 'lost', keyword: '雨伞' });
assert.equal(res.length, 1);
assert.equal(res[0].name, '黑色折叠雨伞');
});
it('无匹配时返回空数组(助教刁难:搜索不存在的词)', function () {
var res = Utils.filterItems(FIX, { keyword: '月球车' });
assert.equal(res.length, 0);
});
});
上述用例分别测试了:关键词匹配名称、大小写/空格归一化、组合筛选、无结果边界四类情况,覆盖搜索模块的主要分支。
6.3 构造测试数据的思路
- 搜索/筛选测试使用独立构造的 fixture 数据:不读取 localStorage 的真实状态,避免用户或测试人员手动操作过数据后(如已标记几条"已找到")导致用例结果不稳定——测试结果与运行环境解耦,任何时候打开都稳定;
- 数据层 CRUD 测试使用真实种子数据 + 独立 id:发布/删除用临时 id(t1/t2)操作后立即还原,状态修改用例标记后也还原为初始状态,保证用例之间互不影响;
- 覆盖正常/边界/异常三类:正常(合法表单、有关键词命中)、边界(空关键词、空数组、图片恰好 3 张)、异常(非法日期、联系方式 1 位、搜索不存在的词、不存在的 id);
- 针对"助教刁难"设计用例:搜索特殊词"月球车"验证空态逻辑、传
null/undefined验证函数不崩溃、多次generateId验证不重复、断言纯函数不修改原数组。
最终共 37 个用例,全部通过(passes: 37, failures: 0)。从覆盖角度看,搜索、校验、状态、时间、CRUD 等核心逻辑的主要分支均已被覆盖,可以满足该程序的测试要求。
七、GitHub 代码签入记录
![GitHub 签入记录截图]
commit 信息遵循「按功能划分、每完成一个功能提交一次」的约定,例如:
feat: 完成数据层 store.js(CRUD + localStorage 持久化)
feat: 完成首页与信息列表渲染
feat: 完成发布页表单与必填校验
feat: 完成搜索页过滤与空态
feat: 完成详情页联系发布者与一键复制
feat: 完成我的发布(编辑/标记状态/删除)
test: 新增 Mocha 单元测试(37 个用例)
docs: 添加 README 目录说明与使用说明
八、遇到的代码模块异常或结对困难及解决方法
| 问题描述 | 做过哪些尝试 | 是否解决 | 有何收获 |
|---|---|---|---|
单元测试首次运行时大量用例失败,经排查是测试页没有先初始化种子数据,Store.getAll() 读到空数组 |
尝试直接运行发现 localStorage 为空;检查后确认 store.js 的初始化逻辑只在页面入口调用,测试页未调用 |
已解决:在测试脚本开头显式调用 Store.init(),并补了一条"初始化"说明 |
测试环境与运行环境要分开初始化,被测模块的依赖状态必须显式管理 |
navigator.clipboard 在 file:// 协议下不可用,复制功能失效 |
先尝试 document.execCommand('copy') 兜底,又封装成 Promise 统一返回结果 |
已解决:采用 clipboard 优先 + execCommand 隐藏 textarea 降级的双通道方案 | 兼容性处理要考虑不同的运行环境,不能只看一种场景 |
| 发布页内容较长,底部表单被固定导航栏遮挡 | 调整页面 padding-bottom 预留空间,给表单区留出足够高度 |
已解决 | 固定定位元素要提前预留占位,布局细节需要在真机/浏览器里反复验证 |
| 搜索时大小写不敏感但中文无大小写概念,英文关键词匹配不稳定 | 统一用 toLowerCase() + 首尾空格裁剪后拼接匹配 |
已解决 | 搜索匹配要先做输入归一化,再进入匹配逻辑 |
九、评价你的队友
值得学习的地方:搭档对页面布局和交互细节很敏锐,发布页的校验提示、列表卡片的信息层级都是他坚持打磨的,最终界面比初版整齐很多;遇到 bug 时习惯先复现再定位,思路清晰。
需要改进的地方:有时候会先动手写再回头梳理结构,建议下次先花十分钟把函数边界和数据流画出来再编码;另外 commit 信息偶尔写得比较随意,统一成 feat:/fix:/docs:/test: 前缀后协作更顺畅。







浙公网安备 33010602011771号