2026秋软件工程结对作业(第二次之程序实现)—— 校园失物招领
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice/homework/16745 |
| 这个作业的目标 | 完成「校园失物招领」核心功能的代码实现,实现「发布信息—浏览/搜索—查看详情—联系发布者—更新状态」的完整流程,并完成单元测试、README 文档与 PSP 记录 |
| 成员一:学号 / 姓名 | 102402125 / 黄俊翔 |
| 成员二:学号 / 姓名 | 102402124 / 洪豊帧 |
| 原型工具 / 在线原型 | 墨刀(Modao)· [https://modao.cc/proto/5AmeweAStm2iuwqkXScZBn/sharing?view_mode=read_only&screen=rbpVWVSUILF2FBjjF](第一次作业使用) |
| 开发工具 / 技术栈 | 原生 HTML + CSS + JavaScript + localStorage(纯前端,零依赖) |
一、结对信息
- 结对同学博客链接:[洪豊帧的博客](https://www.cnblogs.com/honglizhen/【洪豊帧博客园账号】/)
- 我们创建的仓库 GitHub 地址:https://github.com/H3HN/102402125-102402124【H3HN】/102402125-102402124
二、具体分工
| 成员 | 分工内容 |
|---|---|
| 黄俊翔(102402125) | 核心业务逻辑层 model.js、存储层 store.js、单元测试设计(21 个用例 + 测试运行器)、README 文档 |
| 洪豊帧(102402124) | 四个页面 UI(首页/发布/详情/我的发布)与对应页面脚本、整体样式 style.css、博客撰写与 PSP 记录 |
两人通过 Git 协作:由黄俊翔创建仓库,洪豊帧 fork 后以 Pull Request 提交页面代码,双方交叉 Code Review。
三、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 【30】 |
| Estimate | 估计这个任务需要多少时间 | 30 | 【30】 |
| Development | 开发 | ||
| Analysis | 需求分析(包括学习新技术) | 60 | 【60】 |
| Design Spec | 生成设计文档 | 60 | 【60】 |
| Design Review | 设计复审 | 30 | 【30】 |
| Coding Standard | 代码规范 | 30 | 【25】 |
| Design | 具体设计 | 90 | 【88】 |
| Coding | 具体编码 | 240 | 【233】 |
| Code Review | 代码复审 | 60 | 【30】 |
| Test | 测试(自我测试,修改代码,提交修改) | 120 | 【90】 |
| Reporting | 报告 | ||
| Test Report | 测试报告 | 60 | 【30】 |
| Size Measurement | 计算工作量 | 30 | 【23】 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 34 | 【待填】 |
| 合计 | 870 | 【733】 |
说明:预估耗时偏向「具体设计」与「具体编码」,因为本次代码量集中在页面交互与业务逻辑分层上;「测试」环节预留了充足时间用于修正边界情况。实际耗时请两人按真实记录回填,不要与预估完全一致(否则无法体现返工与低估环节)。
四、解题思路描述与设计实现说明
4.1 代码实现思路(文字描述)
本次作业延续第一次结对作业「校园失物招领」的原型设计,把需求收敛到最核心的闭环:
发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态
围绕这一流程,我们在架构上做了明确的分层,把「与界面无关的业务规则」和「界面交互」拆开:
- 业务逻辑层
model.js:纯函数集合,实现信息校验validateItem、信息创建createItem、搜索过滤searchItems、按 id 查找getItemById、状态更新updateItemStatus。这一层不碰document也不碰localStorage,因此可以脱离浏览器单独做单元测试。 - 存储层
store.js:封装localStorage的读写,负责数据持久化、首次访问的种子数据生成、以及「我的发布」id 跟踪。页面代码只调用LFStore提供的接口。 - 页面层(
index.html/publish.html/detail.html/mine.html+ 各自脚本):只做 DOM 渲染、事件绑定与表单收集,数据操作全部委托给store.js。
采用纯前端方案(无需后端、无需数据库)的原因是:作业要求「其他同学下载文件后用谷歌浏览器打开 html 即可复现」,纯前端 + localStorage 能最大程度降低环境门槛,且核心流程(发布、搜索、详情、状态更新)都能完整实现。
关于「联系之后如何结束一条信息」:我们补上了流程闭环 —— 发布者在「我的发布」页或自己信息的详情页点击「标记已找到 / 标记已归还」,状态写入 localStorage 后,所有浏览者会在卡片和详情页看到「已找回 / 已归还」徽章,从源头减少重复询问。谁来改、从哪个入口改、其他人在哪看到结果,三条链路都清晰可走。
4.2 关键实现的流程图 / 数据流图
(1)整体业务流程
(2)代码分层数据流
说明:流程图为 mermaid 源码,若博客园未开启 mermaid 渲染,可使用 mermaid.live 或 draw.io 导出 PNG 后,以图片形式插入(建议放
docs/images/,见成果展示部分)。
4.3 重要的/有价值的代码片段及解释
片段一:信息校验(js/model.js)
function validateItem(data) {
var errors = [];
if (!data) {
return { valid: false, errors: [{ field: 'all', message: '数据为空' }] };
}
if (TYPE.indexOf(data.type) === -1) {
errors.push({ field: 'type', message: '请选择信息类型(寻物/招领)' });
}
if (!data.title || String(data.title).trim() === '') {
errors.push({ field: 'title', message: '请填写物品名称' });
}
if (CATEGORIES.indexOf(data.category) === -1) {
errors.push({ field: 'category', message: '请选择物品类别' });
}
if (!data.location || String(data.location).trim() === '') {
errors.push({ field: 'location', message: '请填写遗失/拾取地点' });
}
if (!data.time || String(data.time).trim() === '') {
errors.push({ field: 'time', message: '请填写时间' });
}
if (!data.contact || String(data.contact).trim() === '') {
errors.push({ field: 'contact', message: '请填写联系方式' });
}
return { valid: errors.length === 0, errors: errors };
}
解释:校验返回 { valid, errors } 而不是直接抛异常,便于页面层一次性拿到所有缺失字段并高亮。这是把「业务规则」下沉到纯函数、让界面只需按结果渲染的关键设计,也是单元测试的重点对象。
片段二:搜索过滤(js/model.js)
function searchItems(items, opts) {
opts = opts || {};
var keyword = (opts.keyword || '').toString().trim().toLowerCase();
var list = (items || []).filter(function (it) {
if (opts.type && it.type !== opts.type) return false;
if (opts.category && opts.category !== '全部' && it.category !== opts.category) return false;
if (opts.status && it.status !== opts.status) return false;
if (keyword) {
var haystack = [it.title, it.category, it.location, it.description]
.join(' ').toLowerCase();
if (haystack.indexOf(keyword) === -1) return false;
}
return true;
});
return list.sort(function (a, b) { return b.createdAt - a.createdAt; });
}
解释:搜索支持关键词(标题/类别/地点/描述模糊匹配)+ 类型 + 类别 + 状态的多条件组合过滤,并默认按发布时间倒序。逻辑独立于页面,方便对「命中标题」「命中地点」「无结果」等边界做白盒测试。
片段三:一键复制联系方式(js/detail.js)
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
showToast('联系方式已复制');
}, function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); showToast('联系方式已复制'); }
catch (e) { showToast('复制失败,请手动复制', true); }
document.body.removeChild(ta);
}
解释:优先用标准 navigator.clipboard,失败或非安全上下文(file:// 协议下 clipboard 可能被禁用)时降级到 execCommand('copy'),保证在「双击 html 文件」的场景下复制也能尽可能工作。
五、附加特点设计与展示
5.1 设计的创意独到之处与意义
- 类型 + 类别实时筛选:首页按「寻物/招领」和物品类别(校园卡、钥匙、手机等)双维度一键过滤,贴合「按类别查找」的真实使用场景。
- 一键复制联系方式:详情页一键复制,免去手动输入手机号/QQ 的麻烦与出错。
- 状态可视化徽章:「进行中 / 已找回 / 已归还」用彩色徽章区分,浏览者一眼识别信息是否已失效,减少无效联系。
- 「我的发布」管理中心:集中管理自己发布过的信息并就地标记状态,形成从发布到闭环的完整链路。
意义:这些特点都紧扣「校园内信息分散、查找不便、联系后信息不闭环」的核心痛点,而非为了堆功能而设计。
5.2 实现思路
- 筛选:
main.js维护{ type, cate, keyword }状态,chips 点击时更新状态并调用store.search(opts)重新渲染,无需刷新页面。 - 复制:见 4.3 片段三,双通道降级。
- 状态徽章:
model.getStatusText(type, status)根据类型 + 状态返回「已找到/已归还/进行中」,卡片与详情页共用。 - 我的发布:发布时
store.myAdd(id)把 id 记入本机lf_my_ids;mine.js读取store.myItems()展示并绑定状态更新按钮。
5.3 值得展示的代码片段
// js/mine.js —— “我的发布”就地标记状态
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
var act = btn.getAttribute('data-act');
if (act === 'view') {
window.location.href = 'detail.html?id=' + encodeURIComponent(id);
} else if (act === 'done') {
var r = store.updateStatus(id, 'resolved');
if (r.ok) {
var it = store.getById(id);
showToast('已标记为「' + model.getStatusText(it.type, 'resolved') + '」');
render();
}
}
});
解释:通过 data-act 区分「查看」与「标记完成」两种操作,事件委托后统一绑定,状态更新成功后即时重渲染,界面与数据保持一致。
5.4 实现成果展示
| 截图 | 说明 |
|---|---|
| 【截图1:首页整体】 | 展示卡片列表、搜索框、类型/类别筛选 chips |
![]() |
|
| 【截图2:搜索演示】 | 输入关键词后过滤结果 |
![]() |
|
| 【截图3:类型/类别筛选】 | 筛选效果 |
![]() |
|
| 【截图4:详情页】 | 描述、联系方式、一键复制、标记按钮 |
![]() |
|
| 【截图5:发布页与校验提示】 | 发布成功 / 必填项高亮提示 |
![]() |
|
| 【截图6:我的发布】 | 管理列表 + 标记状态 |
![]() |
|
| 【截图7:状态更新后】 | 「已找到 / 已归还」徽章 |
![]() |
|
| 【截图8:单元测试报告】 | test/index.html 全部 21 用例通过 |
![]() |
六、目录说明和使用说明
6.1 目录组织
campus-lost-found/
├── index.html # 首页:浏览 + 搜索 + 类型/类别筛选
├── publish.html # 发布页:发布寻物/招领信息
├── detail.html # 详情页:查看详情 + 复制联系方式 + 更新状态
├── mine.html # 我的发布:管理已发布信息、标记状态
├── css/
│ └── style.css # 全站共享样式
├── js/
│ ├── model.js # 核心业务逻辑(纯函数,可单元测试)
│ ├── store.js # localStorage 存储层
│ ├── main.js # 首页逻辑
│ ├── publish.js # 发布页逻辑
│ ├── detail.js # 详情页逻辑
│ └── mine.js # 我的发布逻辑
├── test/
│ ├── index.html # 单元测试页面(浏览器直接打开运行)
│ ├── tests.js # 21 个测试用例 + 轻量测试框架
│ └── run-tests.js # Node 环境运行(可选)
├── docs/
│ ├── 博客报告.md # 本文档
│ └── images/ # 截图素材
└── README.md # 项目说明(目录 + 使用说明)
6.2 测试人员如何运行
- 下载(clone)全部文件,保持目录结构不变。
- 用谷歌浏览器打开
index.html(双击即可)。 - 首次打开自动生成 3 条示例数据,即可体验发布、搜索、详情、状态更新全流程。
- 运行单元测试:用谷歌浏览器打开
test/index.html,页面自动展示测试报告。
注意:数据保存在本机
localStorage,不同浏览器/电脑互不相通;「我的发布」需在发布所用的同一浏览器查看。重置数据可在控制台执行LFStore.clear()后刷新。
七、单元测试
7.1 测试工具与学习过程(含简易教程)
工具选择:本项目为纯前端零依赖,为了让助教「下载即测」,我们没有引入需要 npm install 的 Mocha/Jest,而是写了一个轻量测试框架(内置 test() 注册用例 + assert 断言 + 浏览器报告页 test/index.html),并另提供 run-tests.js 供有 Node 环境的同学命令行运行。
简易教程(三步新增一个用例):
- 打开
test/tests.js,在下方追加:test('用例名称:描述你验证的行为', function () { var r = model.某函数(参数); assert.equal(r.某属性, 期望值, '失败时的提示'); }); - 浏览器打开
test/index.html观察是否 PASS/FAIL。 - 若有 Node:命令行执行
node test/run-tests.js得到同样结果。
7.2 单元测试代码展示
test('validateItem: 缺少 title 报错', function () {
var r = model.validateItem(sampleItem({ title: '' }));
assertFalse(r.valid, '缺少标题应失败');
var fields = r.errors.map(function (e) { return e.field; });
assertTrue(fields.indexOf('title') !== -1, '应包含 title 字段错误');
});
test('updateItemStatus: 非法状态报错', function () {
var it = model.createItem(sampleItem()).item;
var r = model.updateItemStatus([it], it.id, 'deleted');
assertFalse(r.ok, '非法状态应失败');
assertEqual(r.reason, 'invalid-status');
});
test('searchItems: 关键词命中地点', function () {
var items = [
model.createItem(sampleItem({ title: 'A', location: '图书馆' })).item,
model.createItem(sampleItem({ title: 'B', location: '操场' })).item
];
var r = model.searchItems(items, { keyword: '图书馆' });
assertEqual(r.length, 1);
assertEqual(r[0].title, 'A');
});
被测试函数为 validateItem、createItem、searchItems、getItemById、updateItemStatus、getStatusText、getTypeText。
7.3 构造测试数据的思路与对测试人员「刁难」的考虑
采用白盒测试为主,覆盖每个分支:
- 等价类 + 边界:
validateItem分别给「完整 / 空对象 / 缺标题 / 非法类型 / 缺联系方式 / 非法类别」,覆盖每个校验分支。 - c参数组合:
searchItems覆盖「命中标题 / 命中地点 / 无匹配 / 按类型 / 按类别 / 按状态」。 - 异常路径:
updateItemStatus覆盖「正常 / 非法状态 / 不存在的 id」,getItemById覆盖「找到 / 找不到」。
如何应对测试人员刁难:我们主动预判了「空字符串与空白串」「非法枚举值」「不存在的 id」「关键词大小写/前后空格」,并在 createItem 中对标题/地点/联系方式做 trim,搜索时对关键词做 toLowerCase(),尽量让边界输入不导致异常。共 21 个用例,全部通过。
八、遇到的代码模块异常或结对困难及解决方法
问题一:file:// 协议下 navigator.clipboard 被禁用
- 描述:一键复制在直接双击打开 html 时,
navigator.clipboard.writeText因非安全上下文而失败。 - 尝试:仅用 clipboard API → 失败;改用
document.execCommand('copy')。 - 是否解决:已解决(双通道降级,见 4.3 片段三)。
- 收获:纯前端「双击即用」的场景要注意 Web API 的协议限制,需准备降级方案。
问题二:测试用例「按 status 过滤」偶发依赖 id 唯一性
- 描述:若两条测试数据 id 相同,状态更新会误改对象,导致断言失败。
- 尝试:确认
genId()采用「时间戳 + 随机数」保证唯一;并让搜索按createdAt倒序稳定排序。 - 是否解决:已解决。
- 收获:构造测试数据时 id 必须唯一,否则状态类测试会产生隐蔽的相互影响。
问题三:结对协作中代码合并冲突
- 描述:黄俊翔负责逻辑层、洪豊帧负责页面层,合入 PR 时对
store.js接口有过一次冲突。 - 尝试:双方约定「逻辑层接口先行、页面层只调用接口不直接改实现」,冲突手动审阅后合并。
- 是否解决:已解决。
- 收获:协作前先定好分层边界与接口签名,能减少大部分冲突。
九、评价我的队友
值得学习的地方:洪豊帧对页面细节很认真,主动补上了首页卡片到详情、发布按钮到成功提示的跳转交互,并对样式统一性要求高;遇到问题会先自己排查再讨论,减少了无效沟通。
需要改进的地方:提交代码时 commit 粒度偏大,有时一次提交混合了多个功能,建议按「一个功能一次 commit」拆分,便于溯源与 Code Review。








浙公网安备 33010602011771号