软件工程第四次作业
校园失物招领(WEB 版)—— 2026 秋软件工程结对作业(第二次之程序实现)
作业链接
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026 秋软件工程结对作业(第二次之程序实现) |
| 这个作业的目标 | 把上次结对作业的校园失物招领原型做成可运行的 Web 程序,实现发布、浏览搜索、联系与状态标记的完整流程。 |
| GitHub 仓库 | 102401526-102401525 |
| 结对成员 | 102401526 林鹏远、102401525 丁鑫河 |
| 队友博客 | https://www.cnblogs.com/sudaaa/p/23254743 |
| 本作业博客 | https://www.cnblogs.com/-code-yuan/p/23243585 |
一、具体分工
| 成员 | 具体分工 |
|---|---|
| 林鹏远 | 需求分析,编写核心代码,撰写readme,设计单元测试 |
| 丁鑫河 | 需求分析,对readme做审查,设计单元测试,检查核心代码 |
二、PSP 表格
按作业要求,动手前先预估各步骤耗时,完成后记录实际耗时。表格按 PSP 2.1 模板组织,Planning 与 Reporting 两行是汇总行,数值等于其下属子项之和。
| PSP 阶段 | 任务内容 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 40 |
| Estimate | 明确需求与功能边界,估计开发总耗时 | 30 | 40 |
| Development | 开发 | 810 | 1065 |
| Analysis | 需求分析(分析原型 → 可运行程序的差距) | 70 | 85 |
| Design Spec | 设计文档(数据结构、模块划分) | 50 | 70 |
| Design Review | 设计复审(与队友讨论确认) | 30 | 40 |
| Coding Standard | 代码规范(命名、注释、目录约定) | 20 | 15 |
| Design | 具体设计(页面流转、localStorage 键设计) | 100 | 130 |
| Coding | 具体编码(数据层 / 逻辑层 / 视图层) | 400 | 480 |
| Code Review | 代码复审 | 50 | 85 |
| Test | 测试(14 个单元测试 + 浏览器走查,修改代码) | 90 | 160 |
| Reporting | 报告 | 200 | 260 |
| Test Report | 测试报告 | 50 | 60 |
| Size Measurement | 计算工作量 | 20 | 20 |
| Postmortem | 事后总结与过程改进计划 | 130 | 180 |
| 合计 | 1040 | 1365 |
预估与实际的差异分析
实际合计比预估多出约三成,偏差集中在两处:测试环节超时最多,异常输入的边界用例设计远比运行用例费时,不过正是这部分帮我们提前暴露了"改状态后界面不刷新"的同步缺陷;编码环节的超时主要花在状态更新后多视图同步的返工上。其余环节与预估基本持平。改进计划:下次把"异常输入的用例设计"单独立项,并给多视图交互预留返工余量。
三、解题思路描述与设计实现说明
3.1 需求分析与取舍
上次结对作业我们站在"产品经理"视角给出了原型;这次要站在"程序员"视角,把核心流程真正做出来、能用起来。
作业正文给出的核心流程是:发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态。结合老师在前情提要中的提醒(不要因为页面画过就默认解决了按位置查找的问题),我们先把功能边界砍干净:
做的:发布寻物/招领信息(含图片)、集中浏览、关键词 + 分类搜索、详情与联系方式、状态更新与撤销、完结信息的可见性变化。
明确不做的(按作业要求):后台管理、实名认证、即时聊天、地图定位。替代方案是"结构化地点字段 + 编号系统",即用文字地点和唯一编号定位信息,而不是地图。
另外两个老师在博客点评里点名的问题,我们在设计阶段就给出了解法:
- "谁来修改状态?从哪个入口修改?修改后其他用户在哪看到结果?" —— 见 3.3 的状态机设计,这是本次设计的核心。
- "界面画出来和用户能用起来是两回事" —— 所有异常路径(表单不完整、搜索无结果、未来时间、图片超 3 张、存储空间不足)都有真实的交互响应,不只是一条顺利路线。
3.2 代码实现思路(文字描述)
总体架构:三层分离,逻辑层与视图层解耦
┌─────────────────────────────┐
│ 视图层 app.js │ 路由(hash)、6 个视图渲染、DOM 事件
├─────────────────────────────┤
│ 逻辑层 utils.js(纯函数) │ 校验 validateItem、筛选 filterItems、
│ │ 编号 bizNo、图片压缩、复制 —— 可独立单测
├─────────────────────────────┤
│ 数据层 storage.js │ localStorage 读写、示例数据、状态更新
└─────────────────────────────┘
之所以把校验和搜索做成不依赖 DOM 的纯函数,是为了让单元测试可以直接调用函数断言结果,而不需要模拟页面操作——这直接决定了第七节测试的写法。
数据模型:一条信息(item)就是一个普通对象,整个应用只有一条主数据(lf_items_v1)加两条辅助键(本机发布的 id 列表 lf_my_ids_v1、编号自增序列 lf_seq_v1):
{
id: 'k3x9...', // 唯一标识
bizNo: 'XW2026100801', // 编号:寻物XW / 招领ZL + 8位日期 + 2位序号
type: 'lost' | 'found', // 寻物 / 招领
status: 'doing' | 'done', // 进行中 / 已完结
name, category, location, // 名称、分类、地点
happenTime, createTime, // 发生时间、发布时间
description, // 描述
contactName, contact, // 联系人、手机号/微信号
images: [base64, ...] // 最多 3 张,Canvas 压缩后存储
}
"当前用户"的简化:作业不要求实名认证,我们约定"当前浏览器即一名用户"——在这台设备上发布的信息 id 记入 lf_my_ids_v1,即视为"我的发布"。这个约定让状态更新权限判断(只有发布者能完结)和演示(预置 2 条"我的"信息)都变得简单。
呈现形式:移动端 H5 单页应用,hash 路由切换 6 个视图(首页 / 搜索 / 发布 / 详情 / 我的 / 发布成功)。选 WEB 而非 APP 的原因:作业要求"下载所有文件后用谷歌浏览器运行 html 就能展现预期结果",纯静态前端双击即用,零环境依赖。
3.3 关键实现的流程图
图 1:核心业务流程(本次作业要求的端到端主流程)
图 2:信息状态机(回应"谁来改、从哪改、别人在哪看")
图 3:发布表单校验流程(白盒分支的来源,与第六节用例 1~4 对应)
图 4:一次发布的数据流(分层架构的落地)
3.4 重要代码片段与解释
片段 1:表单校验 validateItem(纯函数,逐字段返回错误) —— 不抛异常、不碰 DOM,返回 {valid, errors},视图层拿 errors 逐字段画红字,测试层直接断言。其中"未来时间"分支是老师上次点评"只按顺利路线设计"后我们主动补的防线:
function validateItem(data) {
var errors = {};
data = data || {};
if (!data.name || !String(data.name).trim()) {
errors.name = '请填写物品名称';
} else if (String(data.name).trim().length > 30) {
errors.name = '物品名称不能超过 30 个字';
}
// ...type / category / location / contactName / contact 同理...
if (!data.happenTime) {
errors.happenTime = '请选择时间';
} else if (isNaN(new Date(data.happenTime).getTime())) {
errors.happenTime = '时间格式不正确';
} else if (new Date(data.happenTime).getTime() > Date.now() + 60 * 60 * 1000) {
errors.happenTime = '时间不能晚于当前时间'; // 丢失/拾到不可能发生在未来
}
if (data.images && data.images.length > MAX_IMAGES) {
errors.images = '最多上传 ' + MAX_IMAGES + ' 张图片';
}
return { valid: Object.keys(errors).length === 0, errors: errors };
}
片段 2:搜索筛选 filterItems(关键词 × 类型 × 分类三维叠加,纯函数) —— 关键词对 名称/地点/描述/编号 四字段拼接后的 haystack 做包含匹配;排序规则把"进行中"排在"已完结"之前,保证首页首屏信息有效:
function filterItems(items, cond) {
cond = cond || {};
var kw = String(cond.keyword || '').trim().toLowerCase();
var type = cond.type || '';
var category = cond.category || '';
var result = (items || []).filter(function (it) {
if (type && it.type !== type) return false; // 类型筛选
if (category && it.category !== category) return false; // 分类筛选
if (kw) {
var hay = [it.name, it.location, it.description, it.bizNo]
.join(' ').toLowerCase();
if (hay.indexOf(kw) < 0) return false; // 关键词匹配
}
return true;
});
// 进行中的排在已完结前面;同级按发布时间倒序
return result.sort(function (a, b) {
if ((a.status === 'doing') !== (b.status === 'doing')) {
return a.status === 'doing' ? -1 : 1;
}
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime();
});
}
片段 3:状态更新 updateStatus(核心流程的最后一环) —— 只接受两个合法状态值,其余一律拒绝,防止视图层传错值污染数据:
// status: 'doing' | 'done'
function updateStatus(id, status) {
if (status !== U.STATUS_DOING && status !== U.STATUS_DONE) return false;
var items = getItems();
for (var i = 0; i < items.length; i++) {
if (items[i].id === id) {
items[i].status = status;
write(KEY_ITEMS, items); // 持久化,其他用户(浏览器)下次打开即见
return true;
}
}
return false;
}
片段 4:编号生成 bizNo —— 每条信息有全局唯一业务编号(寻物 XW / 招领 ZL + 日期 + 2 位序号),失主和拾到者线下核对时只报编号即可,这也是详情页和搜索都支持按编号检索的原因。Math.min(99, seq) 处理序号超 99 的截断边界(单元测试覆盖了该分支):
function bizNo(type, date, seq) {
var d = date instanceof Date ? date : new Date(date);
var ymd = d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate());
return (type === TYPE_LOST ? 'XW' : 'ZL') + ymd + pad2(Math.min(99, seq));
}
四、附加特点设计与展示
附加特点共设计了 5 个,按"意义 → 思路 → 代码 → 展示"逐个说明。
4.1 状态更新的双入口 + 撤销完结
- 创意与意义:老师点评里问"谁来修改、从哪个入口修改、修改后其他用户在哪看到结果"。我们把这题当成设计题来做:只有发布者能看到管理入口(详情页管理区 + 「我的」页两个入口,普通浏览者看不到按钮,从权限上杜绝误改);同时提供"撤销完结",因为"已找到"标记错了没法找回会打击使用意愿。
- 实现思路:权限判断用
Store.isMine(id)(本机发布的 id 列表);确认操作走二次确认弹层,防手滑。 - 成果展示:




4.2 一键复制联系方式 / 手机号一键拨号
- 创意与意义:失物招领的高频动作是"联系发布者"。用户在电脑上浏览,掏出手机输入微信号太麻烦,一键复制把操作从"看→记→切设备→输入"缩成一步;纯手机号还提供
tel:链接直接唤起拨号。 - 实现思路:优先
navigator.clipboard,失败降级execCommand('copy')。 - 重要代码:降级路径的细节——
navigator.clipboard只在安全上下文且授权后可用,双击打开的本地页面在不同浏览器下表现不一;降级方案里隐藏的 textarea 也有讲究,必须保持"可选中"状态:
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
// 降级路径兼容性更好;textarea 不能 display:none 或完全透明,否则部分浏览器会跳过复制
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
document.body.appendChild(ta);
ta.focus();
ta.setSelectionRange(0, ta.value.length);
try { document.execCommand('copy') ? resolve() : reject(new Error('复制失败')); }
catch (err) { reject(err); }
document.body.removeChild(ta);
}

4.3 客户端图片压缩
- 创意与意义:localStorage 只有约 5MB,手机原图动辄 3~5MB,直接存一张就爆。压缩让"传图片"这个功能真正可用,而不是摆设。
- 实现思路:
FileReader读图 → 按最大边 800px 等比缩放 → Canvas 重绘 → JPEG(质量 0.72)转 base64,PNG 透明图保留格式。最多 3 张(原型设定,校验强制)。 - 重要代码:
function compressImage(file, maxSize) {
maxSize = maxSize || 800;
return new Promise(function (resolve, reject) {
// ...FileReader 读文件、Image 校验...
img.onload = function () {
var w = img.width, h = img.height;
if (w > maxSize || h > maxSize) { // 等比缩放
if (w >= h) { h = Math.round(h * maxSize / w); w = maxSize; }
else { w = Math.round(w * maxSize / h); h = maxSize; }
}
var canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
var mime = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
resolve(canvas.toDataURL(mime, 0.72)); // JPEG 0.72 平衡画质与体积
};
});
}
- 成果展示:

4.4 搜索无结果的空状态引导
- 创意与意义:老师强调"搜索无结果等异常情况要提前预警"。空状态不做成冷冰冰的"0 条",而是给出两条出路:一键重置筛选、直接跳去发布——把死胡同变成引导。
- 实现思路:
filterItems返回空数组时,视图层渲染空状态组件(本地图标 empty.svg + 两个按钮)。 - 成果展示:

4.5 编号系统 + 类型切换文案联动
- 创意与意义:编号(XW/ZL 前缀)让线下核对不用念完整描述;发布页切换"寻物/招领"时,地点/时间标签自动变为"丢失地点/拾到地点、丢失时间/拾到时间",状态文案也从"寻找中/已找到"对应为"待认领/已归还"——同一套数据结构通过文案映射服务两种业务语义,而不是复制两套代码。
- 实现思路:常量映射表
STATUS_TEXT:
STATUS_TEXT[TYPE_LOST] = { doing: '寻找中', done: '已找到' };
STATUS_TEXT[TYPE_FOUND] = { doing: '待认领', done: '已归还' };


五、目录说明和使用说明
5.1 目录是如何组织的
软件工程作业4/
├── index.html # 应用入口(单页应用,6 个视图在其中切换)
├── css/
│ └── style.css # 全部样式(黑白灰简洁风格、移动端布局)
├── js/
│ ├── utils.js # 逻辑层:校验、搜索筛选、编号、压缩、复制(纯函数,可单测)
│ ├── storage.js # 数据层:localStorage 读写、示例数据、状态更新
│ └── app.js # 视图层:hash 路由、6 个视图渲染与全部交互
├── assets/ # 图片素材(本地 SVG,离线可用)
│ ├── bag.svg / card.svg / headphones.svg / key.svg
│ ├── umbrella.svg / book.svg / cup.svg / watch.svg
│ └── empty.svg # 搜索空状态插图
├── test/
│ └── tests.html # 单元测试页(14 个用例,浏览器直接打开运行)
└── README.md # 目录说明 + 使用说明(与博客本节同步)
组织原则:按职责分目录(样式 / 逻辑 / 数据 / 视图 / 素材 / 测试),其中 utils.js 与 storage.js 不含任何 DOM 操作,tests.html 只引这两个文件即可完成测试,不依赖页面。
5.2 测试人员如何运行
方式一(推荐,即作业要求的测试方式):下载全部文件 → 用谷歌浏览器(Chrome)直接双击打开根目录的 index.html。无需服务器、无需联网、无需安装任何依赖。
方式二(可选):项目根目录启动静态服务器后访问,例如:
# Windows
py -m http.server 8000
# macOS / Linux
python3 -m http.server 8000
体验指引(2 分钟走完核心流程):
- 首页浏览示例信息,顶部可切"全部/寻物/招领",点卡片进详情;
- 详情页点"复制微信号/手机号",或直接"拨打";
- 底部"+ 发布"发布一条寻物信息(试试留空提交看校验提示);
- 底部"我的"→ 对刚发布的(或预置的 2 条"我的"信息)点"标记已找到",回首页看置灰效果;
- 搜索框输入不存在的词(如"电视机")看空状态页;
- 在详情页/我的页"撤销完结",信息恢复进行中。
数据说明:数据存浏览器 localStorage,预置 10 条示例(含 2 条"我的发布"、2 条已完结)。在控制台执行 Store.resetData() 可随时恢复初始示例数据。




六、单元测试
6.1 测试工具与简易教程
选用的工具:没有引入 Jest/Mocha 等框架,而是用原生 JS 自写了一个 30 行的极简测试运行器,放在 test/tests.html 里。原因有三:① 作业要求"下载后浏览器直接打开就能测",框架需要 node/npm 环境;② 我们的逻辑层是无 DOM 依赖的纯函数,断言足够;③ 顺便借此理解测试框架的本质(runner + assert 的封装)。
我们是如何学习单元测试的:先过了一遍等价类划分、边界值分析、白盒分支覆盖的概念(软件工程教材 + 课堂内容),再对照上机实践。下面是我们的简易教程(也是我们实际做的事):
第 1 步:写运行器 —— it(用例名, 步骤, 函数) 收集结果,assert/eq 抛错即失败:
function it(name, steps, fn) {
try { fn(); results.push({ name, steps, pass: true }); }
catch (e) { results.push({ name, steps, pass: false, err: e.message }); }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || '断言失败'); }
function eq(a, b, msg) {
if (a !== b) throw new Error(msg + ':期望「' + b + '」,实际「' + a + '」');
}
第 2 步:写夹具 —— validForm(override) 生成一份合法表单数据,
每个用例前 Store.resetData() 恢复干净环境,用例互不污染。
第 3 步:设计用例 —— 白盒对着 validateItem 的每个判定分支、
bizNo 的序号边界(99 截断)写;黑盒按等价类给搜索输入分类,
用错误推测法补"撤销完结"这类容易漏的路径。
第 4 步:跑 + 看 —— 浏览器打开 tests.html,绿=PASS 红=FAIL,
失败会打印"期望值 vs 实际值"。

6.2 测试代码展示(说明测试的函数)
共 14 个用例,覆盖四个模块加一组白盒专项:表单校验(用例 1~4)、发布入库(用例 5~6)、搜索筛选(用例 7~10)、状态更新(用例 11~12)、白盒专项——分支/边界(用例 13~14)。测试的函数是 Utils.validateItem、Utils.filterItems、Utils.matchKeyword、Utils.bizNo(经发布流程与边界用例)、Store.addItem、Store.updateStatus。举三个有代表性的:
// 用例 3(白盒-分支):validateItem 的未来时间拒绝分支
it('用例 3:表单校验 —— 丢失/拾到时间晚于当前时间时拒绝', '...', function () {
var future = new Date(Date.now() + 24 * 3600000).toISOString();
var r = U.validateItem(validForm({ happenTime: future }));
assert(!r.valid, '未来时间应校验失败');
assert(!!r.errors.happenTime, '应给出 happenTime 错误提示');
});
// 用例 5(集成):addItem 入库 + bizNo 前缀与格式 + "我的发布"归属
it('用例 5:发布信息 —— 新增寻物信息后可被查询,编号以 XW 开头', '...', function () {
reset();
var item = S.addItem(validForm());
assert(!!item && !!item.id, '应返回带 id 的新信息');
var got = S.getItem(item.id);
eq(got.name, '测试物品', '名称应一致');
eq(got.status, U.STATUS_DOING, '新信息状态应为 doing');
assert(/^XW\d{8}\d{2}$/.test(got.bizNo), '寻物编号应为 XW+日期+序号,实际:' + got.bizNo);
assert(S.isMine(item.id), '新发布的信息应属于"我的发布"');
reset();
});
// 用例 12(错误推测):完结后撤销恢复 —— 对应状态机的 done→doing 双向边
it('用例 12:状态更新 —— 招领信息完结后可撤销恢复为「待认领」', '...', function () {
reset();
var target = S.getItems().filter(function (it) { return it.type === U.TYPE_FOUND; })[0];
S.updateStatus(target.id, U.STATUS_DONE);
S.updateStatus(target.id, U.STATUS_DOING);
var got = S.getItem(target.id);
eq(got.status, U.STATUS_DOING, '撤销后应恢复 doing');
eq(U.STATUS_TEXT[U.TYPE_FOUND][U.STATUS_DOING], '待认领', '文案应为「待认领」');
reset();
});
6.3 构造测试数据的思路
如何考虑各种情况:三层思路——
- 白盒对着代码分支造:
validateItem内部共有 12 个判定分支(名称 2 个、类型/分类/地点/联系人/联系方式各 1 个、时间 3 个、描述 1 个、图片 1 个)。单元测试直接覆盖其中 5 类关键输入——空名称、完整合法通过、未来时间、图片超 3 张、超长名称(用例 1~4 及 13);其余分支(空地点、空联系方式、非法类型等)由发布页的行内校验兜底,并通过浏览器人工走查验证。bizNo的Math.min(99, seq)是显式边界,单独设边界用例(用例 14)。 - 黑盒按等价类造:搜索输入划成三类等价类——命中名称("校园卡")、命中地点/描述("图书馆")、完全无命中("电视机");再叠加"类型+分类"组合筛选验证条件之间是 AND 关系。
- 错误推测法兜底:想"用户最可能怎么把程序玩坏"——状态来回切(完结→撤销→再完结)、存满(
write失败走 alert 提示分支)、时间恰好等于当前(±1 小时容差边界)。
如何考虑将来测试人员的刁难:
- 数据隔离:每个用例前
resetData(),测试人员手动玩过应用后再跑测试,结果不受脏数据影响;跑完同样恢复示例,不破坏演示环境。 - 输入容错:测试人员可能贴超长文本、全空格、未来日期——这些全部被校验拦在界面层,不会写入脏数据。
- 可重复:不依赖网络、不依赖执行顺序,tests.html 刷新即重跑;失败信息打印"期望 vs 实际",照着就能定位。
七、GitHub 代码签入记录
仓库:https://github.com/codeyuan1/102401526-102401525
提交按开发阶段分批签入,commit message 与实际工作内容一一对应:
| # | 提交信息 | 对应工作 |
|---|---|---|
| 1 | 搭建项目骨架:入口页面与黑白灰移动端样式 | index.html + style.css |
| 2 | 添加工具函数与localStorage数据层(含校验、筛选、示例数据) | utils.js + storage.js |
| 3 | 实现六大视图与核心交互(发布/搜索/详情/状态更新) | app.js |
| 4 | 添加分类图标与空状态SVG素材(本地离线可用) | assets/ |
| 5 | 添加14个单元测试用例(白盒分支/边界+黑盒等价类) | test/tests.html |
| 6 | 完善README:目录结构、使用说明与测试设计方法 | README.md |
| 7 | chore: 添加.gitignore忽略系统文件 | .gitignore |

八、遇到的代码模块异常或结对困难及解决方法
困难 1:图片上传撑爆 localStorage
- 问题描述:手机照片动辄 3~5MB,而 localStorage 总容量只有约 5MB,传两张原图就抛 QuotaExceededError,发布失败。
- 做过哪些尝试:写入前用 Canvas 压缩——按最长边 800px 等比缩小、以质量 0.72 重新编码为 JPEG,单张压到 100KB 以内;压缩依赖 img.onload,提交流程随之改成 Promise 链等待压缩完成再写库。
- 是否解决:解决。压缩后单张降到几十 KB,3 张图片加全部文字一次写入无压力。
- 有何收获:localStorage 容量要精打细算;带图片的提交天然是异步流程,同步思维会写出竞态。
困难 2:状态更新后多视图不同步
- 问题描述:详情页把信息标记为"已归还"后,返回列表卡片还是"进行中",首页统计也没变——数据改了,界面没跟上。
- 做过哪些尝试:约定所有状态变更必须走 Store.updateStatus 这一个口子,变更后重渲染当前视图,切回其他视图由 hash 路由触发重渲染。
- 是否解决:解决。列表卡片、详情横幅、统计数字三处显示一致。
- 有何收获:多视图共享数据时,"谁通知界面"要开工前定规则;这正是"改完别人在哪看到结果"的工程答案。
九、评价你的队友
值得学习的地方:丁鑫河在需求分析阶段很有想法,并且敢于砍掉不合理,难以实现的需求;我有些代码上的小漏洞,也是他通过细心的测试发现出来。
需要改进的地方:他介入开发偏晚,前期以把关和测试为主,如果能把审查中发现的问题自己动手改掉一部分,结对开发的效率会更高。

浙公网安备 33010602011771号