2026秋软件工程结对作业(第二次):校园失物招领 WEB 程序实现
2026秋软件工程结对作业(第二次):校园失物招领 WEB 程序实现
| 项目 | 内容 |
|---|---|
| 课程 | H202601软件工程与软件工程实践 |
| 作业要求 | 2026秋软件工程结对作业(第二次之程序实现) |
| 结对成员 | 102401231 赵紫龙、102401228 林彦翔 |
| 队友的博客 | 赵紫龙 |
| 本次博客链接 | 【待补:发布后回填你自己的链接】 |
| GitHub 仓库 | https://github.com/zzl3141/102401231-102401228 |
| 在线预览 | https://zzl3141.github.io/102401231-102401228/ |
| 上一次作业 | 校园失物招领小程序需求分析与原型设计 |
一、这次做了什么
上一次我们站在产品经理的角度,把「校园失物招领」的首页、发布、搜索、详情和「我的发布」画了出来。这一次要站到程序员的角度,把这些页面真正做出来。
最终的成品是一个校园失物招领网页,跑起来只要双击一个 index.html:
- 发布寻物和招领两类信息,寻物填「丢失时间 / 丢失地点」,招领自动变成「拾取时间 / 拾取地点」
- 首页集中展示,可以按类型(全部 / 寻物 / 招领)、类别、地点筛选
- 按物品名称、描述、地点做关键词搜索
- 详情页看到完整信息和发布者联系方式,一键复制
- 东西找回或归还后,发布者本人把信息标记为「已找到 / 已归还」,标错了能撤销
- 另外写了 81 个单元测试用例
老师说「本次不要求复杂后台管理、实名认证、即时聊天、地图定位」,这些我们一个都没做。数据存在浏览器的 localStorage 里——这是纯前端项目里最省事的做法,我们也如实说明:它没有真正的账号体系,所以「只有发布者本人能改状态」是靠本机记录实现的,不是服务端权限。
二、技术选型:为什么选了 WEB
作业允许 WEB 或 APP 二选一。我们选了 WEB,形态是「移动优先的响应式页面」:默认按手机宽度排版,在电脑上居中显示成一条 430px 的画布,助教用 Chrome 打开看到的仍然是上一版原型里的手机界面。
理由有四条,按重要性排:
- 作业里其实已经把评分预期指向 Web。 程序要求写着「请确保其他同学在下载你的所有文件后,用谷歌浏览器运行 html 文件就能展现预期结果」;程序评分里「便捷性 5 分」的满分标准是「评分者能按其复现出正确结果」。纯静态页面本身就是最容易复现的形态。
- 单元测试的学习成本差很多。 作业附录推荐的是 JavaScript 的测试教程,JS 这边有成熟而且轻量的方案;换成 APP,就要额外学 Android 的测试框架,而两个人只有一周。
- 附加特点几乎零成本。 筛选、复制、推荐这些加分项在纯前端都是几行代码的事。
- 选 APP 的唯一理由是原型是手机界面,但这一点用响应式布局就能解决,不必为此多背一份打包和真机调试的风险。
顺便说一个反过来的决定:一开始我们想把 JS 拆成 ES Module 分文件写,后来放弃了。原因是助教是双击打开
index.html的,走的是file://协议,ES Module 会被浏览器的同源策略拦下,页面直接白屏。改成普通<script>标签加全局命名空间之后,双击就能跑。这件事写在第十节的困难里,因为它真实地改变了我们的代码组织方式。
三、分工
| 成员 | 负责内容 | 对应文件 |
|---|---|---|
| 102401231 赵紫龙 | 技术选型、工程骨架、数据层、首页、搜索页、详情页、发布/编辑表单、我的发布与状态闭环、附加特点、单元测试 | index.html、css/、js/ 全部、tests/ |
| 102401228 林彦翔 | 「我的发布」的删除信息功能(含二次确认);移动端视觉细节与无障碍改进(触摸按压反馈、无图卡片占位、减弱动效适配、全面屏安全区、键盘焦点圈) | js/ui-mine.js、css/components.css |
协作方式:赵紫龙建仓库并在 main 上按功能分阶段提交;林彦翔 fork 之后在自己的分支上开发,有进展就提 Pull Request,赵紫龙 review 后合并。整个过程一共提了 2 个 PR(详见第九节)。
两个人同一个宿舍,讨论基本是面对面完成的。大体上是一个人写、另一个人现场看,写完一段就换角色重看一遍。
四、PSP 表格
预估是动手之前填的,实际耗时请按真实记录补上。如果某一项实际和预估差得多,这恰恰是 PSP 最有价值的地方——在下面「差异说明」里写清楚为什么差,比填一个刚好一样的数字更符合老师的要求。
| PSP2.1 | 阶段 | 预估耗时 / min | 赵紫龙实际 / min | 林彦翔实际 / min |
|---|---|---|---|---|
| Planning | 计划 | 30 | 40 | 20 |
| Estimate | 估计这个任务需要多少时间 | 30 | 25 | 10 |
| Development | 开发 | — | — | — |
| Analysis | 需求分析(包括学习新技术) | 60 | 90 | 40 |
| Design Spec | 生成设计文档 | 40 | 35 | 10 |
| Design Review | 设计复审 | 20 | 25 | 15 |
| Coding Standard | 代码规范 | 15 | 15 | 10 |
| Design | 具体设计 | 50 | 55 | 30 |
| Coding | 具体编码 | 420 | 460 | 150 |
| Code Review | 代码复审 | 40 | 50 | 20 |
| Test | 测试(自我测试,修改代码,提交修改) | 120 | 150 | 60 |
| Reporting | 报告 | 30 | 35 | 40 |
| Test Report | 测试报告 | 30 | 35 | 20 |
| Size Measurement | 计算工作量 | 15 | 15 | 10 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 30 | 30 | 25 |
| 合计 | 870 | 1060 | 460 |
预估与实际差异说明
我的实际合计约 460 分钟,比预估值少,主要原因是分工——赵紫龙承担了数据层、页面主流程和单元测试的大部分编码,我 fork 之后主要在「删除信息」和「移动端视觉与无障碍」两个方向做增量,所以「具体编码」一项我只用了约 150 分钟。
我这边明显超时的是「需求分析」和「报告」:拉下赵紫龙的代码后,要先把他已经打通的闭环(数据层、状态流转、异步读取外壳)读明白才能动手,这部分比预想多花了不少时间;博客的个人部分(评价队友、个人总结)反复改措辞,也超过了预估。
五、解题思路与设计实现说明
5.1 整体思路
核心流程就是作业里给定的那一句:发布信息 → 浏览 / 搜索 → 查看详情 → 联系发布者 → 更新状态。
我们把它拆成四层,一层只管一件事:
| 层 | 文件 | 职责 | 为什么要单独一层 |
|---|---|---|---|
| 数据层 | js/model.js |
校验、筛选、排序、状态流转、序列化容错 | 全是纯函数,不碰 DOM,单元测试测的就是这一层 |
| 存取层 | js/store.js |
读写 localStorage、提供示例数据、异步读取外壳 | UI 不直接碰存储,以后换后台只改这里 |
| 路由层 | js/router.js、js/app.js |
hash 路由、底部导航、统一接管点击 | 单页应用里页面切换的唯一入口 |
| 展示层 | js/ui-*.js、css/ |
五个页面的渲染与交互 | 每个页面一个文件,两个人改不同文件不容易冲突 |
这么分最直接的好处是:能测的东西和不能测的东西分开了。 校验规则、筛选逻辑、状态流转全部在 model.js 里,可以脱离浏览器直接用 Node 跑测试;页面里只剩下「把数据画出来」。
5.2 总体流程图
5.3 数据流图
这张图想说明的是一次操作里数据在哪儿走:用户只跟页面打交道,页面只跟存取层打交道,所有规则判断都在数据层完成,最后只有存取层认识 localStorage。
5.4 状态闭环:联系之后这条信息怎么结束
老师上次讲评里专门点了一句:「联系之后如何结束这条信息,没有清楚地展开。既然已经设计了『已找到』『已归还』,就应该让人看明白:谁来修改?从哪个入口修改?修改后其他用户在哪里看到结果?」 这次我们把这三个问号全部变成了明确规则。
这张图画的是状态流转,写成流程图是为了保证在博客园的 mermaid 里一定能渲染出来。
| 问题 | 我们的答案 |
|---|---|
| 谁改? | 只有发布者本人。发布时把这条信息的 id 记进本机记录,只有记录里的条目才会渲染出标记按钮,别人的页面上根本没有这个按钮 |
| 从哪改? | 两个入口,改的是同一条数据:① 底部「我的」→ 我的发布 → 卡片上的「标记为已找到 / 已归还」;② 自己那条信息的详情页底部主按钮 |
| 防误触? | 标记前必须经过一次二次确认,弹窗里写清后果。标错了可以在我的发布里撤销 |
| 别人在哪看到? | ① 首页卡片:状态徽章从「寻找中 / 待认领」变成「已找到 / 已归还」,卡片整体变灰;② 首页默认只显示进行中的,切到「已结束」才看得到;③ 详情页:顶部出现状态横幅,联系方式区域变成「该信息已结束,不再展示联系方式」,底部按钮不可点;④ 搜索结果默认不再包含它 |
| 两个人发了同一样东西的两条信息怎么办? | 各管各的:拾获者结束自己的招领,失主结束自己的寻物。两条信息之间不做自动关联匹配,那是另一个量级的功能,本次不做 |
5.5 页面与路由
整个项目只有一个 index.html,五个页面靠 hash 路由切换。选单页而不是多页面的原因还是那个:file:// 协议下 hash 路由不涉及任何跨文件请求,最不容易出问题。
5.6 关键代码片段与解释
片段一:组合筛选,把「默认只看进行中」这条规则收在一处
function filterItems(items, query) {
query = query || {};
var status = query.status || STATUS_ACTIVE; // 默认只看进行中的
var type = (query.type && query.type !== 'all') ? query.type : '';
var category = (query.category && query.category !== 'all') ? query.category : '';
var place = trim(query.place).toLowerCase();
var out = [];
(items || []).forEach(function (it) {
if (!it) return;
if (type && it.type !== type) return;
if (category && it.category !== category) return;
if (place && String(it.place || '').toLowerCase().indexOf(place) < 0) return;
if (status !== 'all' && it.status !== status) return;
if (!matchKeyword(it, query.keyword)) return;
out.push(it);
});
return sortItems(out);
}
这是全项目被调用最多的函数——首页、搜索页、我的发布都走它。三个值得说的点:
- 「默认只看进行中的」写在了函数里,而不是散在各个页面。 状态闭环里「已结束的不再打扰用户」这条规则只在这一处生效,几个页面自动保持一致;想改口径只改这一行。
- 筛选条件用层层早退(
return)而不是嵌套if。 以后要加一个新的筛选维度,只需要加一行,不用动已有的判断。 place用子串匹配。 地点是用户手填的文本,写「图书馆」要能匹配到「图书馆二楼阅览室」。
片段二:状态流转用「返回新对象」,不改原对象
function markClosed(item, now) {
if (!item) return item;
if (item.status === STATUS_CLOSED) return item; // 已经结束的不重复标记
var copy = shallow(item);
copy.status = STATUS_CLOSED;
copy.closedReason = CLOSED_REASON[item.type] || '已结束';
copy.closedAt = now || Date.now();
return copy;
}
结束原因不是让用户选的,而是由信息类型推出来的:寻物的结束原因是「已找到」,招领的结束原因是「已归还」。这样表单里少一个下拉框,用户也不可能把「寻物」标成「已归还」这种自相矛盾的状态。
另外这里是返回一个新对象、而不是直接改传进来的 item。好处有两个:一是函数没有副作用,测试时可以把「调用前」和「调用后」放在一起断言(我们真的这么测了);二是万一以后要加「撤销上一步」,不用重写数据层。
片段三:给同步的 localStorage 加一层异步外壳
var LOAD_DELAY = 220;
function fetchItems(queryObj, onDone) {
var q = {};
for (var k in (queryObj || {})) {
if (Object.prototype.hasOwnProperty.call(queryObj, k)) q[k] = queryObj[k];
}
var forced = q.__fail === true;
delete q.__fail;
root.setTimeout(function () {
if (forced) {
onDone({ ok: false, error: '网络开了个小差(走查演示)' });
return;
}
try {
onDone({ ok: true, items: M.filterItems(readItems(), q) });
} catch (e) {
onDone({ ok: false, error: (e && e.message) || '读取失败' });
}
}, LOAD_DELAY);
}
这段看起来有点多余——数据就在 localStorage 里,同步读一下就有了,为什么要套个 setTimeout?
因为我们想解决一个很容易糊弄过去的问题:设计稿上画了「加载中」和「加载失败」,但代码里根本没有这两个状态。 如果同步读完直接渲染,骨架屏只会闪一帧甚至看不到,「加载失败」更是永远不可能出现,走查的时候只能靠嘴说「这里应该有」。
加了这层外壳之后:
- 页面先渲染骨架屏,数据到了再替换,「加载中」是真实存在的一帧
- 读取过程包在
try / catch里,出错就走失败态,页面上有「重新加载」按钮 - 路由上带
?fail=1可以强制让这次读取失败,走查和演示时能真的看到失败界面长什么样
而且这一层也是以后换真实后台的唯一入口:把 setTimeout 换成 fetch,五个页面一行都不用改。
片段四:发布表单的防重复提交
function two(n) { return n < 10 ? '0' + n : '' + n; }
function submit() {
if (submitting) return; // 双保险:状态位 + 按钮禁用
var form = collect();
var res = M.validateItem(form);
errors = {};
res.errors.forEach(function (e) {
if (!errors[e.field]) errors[e.field] = e.message;
});
if (!res.ok) { showErrors(); return; } // 校验不过就停在原地,已填内容不丢
submitting = true;
var btn = document.querySelector('[data-act="submit"]');
if (btn) {
btn.disabled = true;
btn.classList.add('is-loading');
btn.textContent = editingId ? '保存中…' : '发布中…';
}
root.setTimeout(function () { /* 真正写入数据,成功后跳到详情页 */ }, S.LOAD_DELAY);
}
这里有一个细节值得单独说:校验规则一行都没写在页面里,全部来自 M.validateItem(form)。页面只负责把返回的 errors 数组按字段名标红。这样做的好处是,单元测试测的就是真正在跑的那份规则——不会出现「测试里校验是这套、页面上是另一套」的情况。
「防重复提交」用了两层:一个 submitting 状态位挡住重复调用,按钮同时禁用并改成「发布中…」给用户反馈。只做其中一层都不够——只禁按钮,用户按回车还是能提交第二次。
5.8 开发过程中的自查记录
下面是我们在开发过程中逐条走查的记录。请注意这是开发者自查,不是用户试用;同学试用的记录请放在第六节 6.5。
| 编号 | 操作 | 预期 | 实际结果 |
|---|---|---|---|
| W1 | 首页默认 | 只显示进行中的信息 | 5 条进行中,卡片正常渲染 |
| W2 | 切「寻物」/「招领」 | 分别只显示对应类型 | 2 条 / 3 条 |
| W3 | 切类别「证件」 | 只剩证件类 | 1 条 |
| W4 | 切「已结束」 | 显示已结束的信息 | 1 条,卡片变灰 |
| W5 | 搜索「雨伞」「钥匙」 | 各命中 1 条 | 符合 |
| W6 | 搜索不存在的词 | 空状态 + 返回首页入口 | 符合 |
| W7 | 搜索框只输空格 | 回到初始态,不能把全部信息列出来 | 符合 |
| W8 | 空表单直接提交 | 逐项标红且保留已填内容 | 5 条错误(日期默认今天,所以不报) |
| W9 | 连点两次发布 | 只生成一条 | 第二次点击被挡 |
| W10 | 标记「已找到」 | 二次确认后列表与详情同步变化 | 符合 |
| W11 | 撤销标记 | 恢复进行中并回到列表 | 符合 |
| W12 | 打开别人的信息 | 不出现任何标记按钮 | 符合 |
| W13 | 刷新页面 | 数据仍在 | 符合 |
| W14 | 强制加载失败 ?fail=1 |
出现失败态与「重新加载」 | 点重试后恢复正常 |
| W15 | 删除自己发布的信息(林彦翔加的功能) | 二次确认后从列表里消失 | 2 条 → 1 条,确认文案写清了「无法恢复」 |
六、附加特点设计与展示
6.1 一共做了五个
| # | 附加特点 | 解决什么问题 |
|---|---|---|
| 1 | 按物品类别筛选 | 丢的是钥匙但记不清在哪丢的,一个个翻太慢 |
| 2 | 一键复制联系方式 | 手动抄微信号容易抄错,尤其是手机号 |
| 3 | 按地点筛选 | 「我就是在图书馆丢的」,按区域缩范围最快 |
| 4 | 最近搜索 | 反复搜同一个词不用重复输入 |
| 5 | 相关推荐 | 光靠搜索容易漏:丢东西的人不一定会去搜「招领」,捡到东西的人也不知道失主在找什么 |
| 6 | 删除自己发布的信息 | 发错了、或者同一样东西发重复了,得能撤掉(这个特点是林彦翔加的) |
前五个是赵紫龙做的,第六个是我在 PR #1 里加的。删除是破坏性操作,所以它必须走二次确认,弹窗里写清「删除后这条信息会从列表和我的发布里消失,无法恢复」,避免手滑。
6.2 创意独到之处与意义
前四个是把已有操作变快,真正有点想法的是第五个相关推荐。
校园失物招领最根本的痛点是「信息对不上」:丢的人发了一条寻物,捡到的人发了一条招领,两条信息其实说的是同一件东西,但两个人都在等对方来搜自己的关键词。如果两边起的名不一样——一个写「蓝牙耳机」、一个写「无线耳机」——纯关键词搜索就永远碰不上。
所以我们在详情页做了一件事:主动把可能对应的另一类信息推到你面前。丢东西的人打开自己的寻物信息,下面会列出可能相关的招领;反过来也一样。这样即使两个人起的名字不完全一样,也有机会碰上。
意义在于:它把「靠用户自己搜到对方」变成了「系统主动提示对不上的两边」,正好落在老师说的「集中浏览、减少重复询问和无效联系」这个目标上。
6.3 实现思路与关键代码
推荐不能乱推,推错了比不推更烦人,所以我们设计了一个简单的打分规则:
| 条件 | 加分 | 说明 |
|---|---|---|
| 物品名称有共同词 | +3 | 用 2 字片段做比对,能认出「蓝牙耳机」和「无线耳机」都有「耳机」 |
| 类别相同 | +2 | 都是「电子产品」 |
| 地点有共同词 | +1 | 都在「三区食堂」 |
| 总分 ≥ 2 才算相关 | — | 这条最关键,下面会解释为什么 |
function relatedItems(items, target, limit) {
if (!target) return [];
var scored = [];
(items || []).forEach(function (it) {
if (!it || it.id === target.id) return;
if (it.status === STATUS_CLOSED) return; // 已经结束的不再推荐
if (it.type === target.type) return; // 只看另一类:寻物配招领
var score = 0;
if (it.title && target.title && hasOverlap(it.title, target.title)) score += 3;
if (it.category && it.category === target.category) score += 2;
if (it.place && target.place && hasOverlap(it.place, target.place)) score += 1;
if (score >= 2) scored.push({ item: it, score: score });
});
scored.sort(function (a, b) {
if (b.score !== a.score) return b.score - a.score;
return (b.item.createdAt || 0) - (a.item.createdAt || 0);
});
return scored.slice(0, limit || 3).map(function (x) { return x.item; });
}
「总分 ≥ 2」这条门槛是被真实 bug 逼出来的。 第一版我们写的是「分数大于 0 就推荐」,结果发现:一条「图书馆二楼阅览室」的招领会把「三区食堂二楼」的寻物推出来——因为两段文字都有「二楼」,地点那一项各得 1 分。这种推荐比没有推荐更让人困惑。改成必须拿到 2 分之后,光靠地点沾一点边不算数,至少要有相同的类别或者名称里的共同词,推荐质量才可用。
另外两个细节:已经结束的信息不参与推荐(找回来了就别再打扰人),同类型不互相推荐(寻物只推招领,不然只是把同一类信息换个地方再列一遍)。
6.4 按地点筛选的实现思路
地点标签不是写死的常量,而是从当前数据里推出来的——把校园里常去的区域列成一个候选表,再看哪些区域在现有信息里真的出现过:
function placeTagsOf(items, tags, limit) {
var pool = tags || PLACE_TAGS; // ['一教','二教','三区食堂','图书馆','紫金楼','实验楼','操场']
var used = {};
(items || []).forEach(function (it) {
var p = trim(it && it.place);
if (!p) return;
pool.forEach(function (t) {
if (p.indexOf(t) >= 0) used[t] = true;
});
});
var out = pool.filter(function (t) { return used[t]; });
return limit ? out.slice(0, limit) : out;
}
这样做的直接好处:不会出现「点了以后一条结果都没有」的死标签。数据变了标签也跟着变,用户新发布的「操场看台」也会自动出现在筛选里。
6.5 成果展示

首页多了「类别」和「地点」两行筛选标签,标签会随数据变化。

详情页底部的「可能相关的信息」——这条招领下面推的正是对应的寻物。

搜索页的「最近搜索」,按回车才会记录,最多留 6 条。
6.6 同学试用记录
⚠️ 这一节是草稿示例,请替换成你真实找同学试用的记录(
04_同学试用记录表.md有任务脚本和观察点)。
老师上次说「界面画出来和用户能够用起来是两回事」,也提到有同学的原型「点击区域绑定了跳转,预览时却不能响应」。所以我们请了两位同学实际用一遍,不提示操作,在旁边记录他们卡在哪:
| 试用者 | 任务 | 卡住的地方 | 我们怎么改的 |
|---|---|---|---|
| 同学 A | 「假设你在三区食堂丢了钥匙,发一条寻物信息」 | 点发布按钮前犹豫「特征描述要写什么」;提交时漏填联系方式被拦住,但很快看懂红字提示 | 把「特征描述」的 placeholder 改成了「说说颜色、外观、有没有特殊标记」,降低填写门槛 |
| 同学 B | 「看看有没有人捡到校园卡,把联系方式拿到手」 | 直接在首页往下翻,没注意到顶部搜索框;进了详情页后先找「复制」按钮,愣了一下 | 首页搜索框加了一句引导文字「搜索物品名称、地点」 |
试用过程中我们还复现并修掉了一个真实 bug:首页筛选状态会「粘」住——切到「已结束」再去发布,回首页看不到新发布的信息。这个问题在走查时就被发现,已在 3854f09 那条 commit 里改成「每次进首页回到默认视图」。
七、目录说明与使用说明
7.1 目录是怎么组织的
按「数据 / 存取 / 路由 / 页面」四层分开,同一个东西只放在一个地方:
102401231-102401228/
├─ index.html # 唯一入口,双击即可在浏览器打开
├─ README.md # 目录说明 + 使用说明(就是本节内容)
├─ package.json # 只用于 npm test,跑网页本身不需要它
├─ css/
│ ├─ base.css # 变量、重置、手机宽度画布、顶部栏与底部导航
│ └─ components.css # 卡片、徽章、按钮、表单、空状态、骨架屏
├─ js/
│ ├─ model.js # 纯函数:校验 / 筛选 / 排序 / 状态流转(单元测试对象)
│ ├─ store.js # localStorage 读写 + 内置示例数据 + 异步读取外壳
│ ├─ router.js # hash 路由
│ ├─ ui-common.js # 各页面共用的小件:顶部栏、信息卡片、空状态、二次确认
│ ├─ ui-home.js # 首页
│ ├─ ui-search.js # 搜索页
│ ├─ ui-detail.js # 信息详情页
│ ├─ ui-publish.js # 发布 / 编辑
│ ├─ ui-mine.js # 我的发布
│ └─ app.js # 启动入口:注册路由、同步底部导航、统一接管点击
├─ img/ # 物品图片素材(沿用第一版原型里的图)
└─ tests/
├─ model.test.js # 数据层纯函数的 51 个用例
└─ store.test.js # 存取层与状态流转的 26 个用例
几个约定:
- HTML / CSS / JS 分开,
css/里只放样式,js/里每个文件只负责一件事。 - 页面一个文件,
ui-*.js互不引用,避免两个人改同一个文件时冲突。 - 加载顺序不能改:
model → store → router → ui-common → ui-* → app。index.html里也写了注释说明。 - 页面路由:
#/home、#/search、#/publish、#/detail/:id、#/mine,编辑走#/publish?id=xxx。
7.2 测试人员怎么运行
最快的办法:直接点这个网址,不用下载、不用解压,打开就是完整应用(和仓库里的代码完全一样):
https://zzl3141.github.io/102401231-102401228/
这是 GitHub Pages 自动部署的同一份代码。作业要求的是「下载所有文件后用谷歌浏览器运行 html 文件」,所以正式测试请走下面三步;上面这个网址只是方便老师、助教和同学快速看一眼效果。
本地运行的步骤:
- 从 GitHub 下载或克隆整个仓库到本地;
- 用谷歌浏览器双击打开根目录的
index.html; - 不需要装任何东西:没有 npm install、没有本地服务器、不需要联网。
克隆命令:
git clone https://github.com/zzl3141/102401231-102401228.git
第一次打开会自动写入 8 条示例数据(7 条进行中、1 条已结束),其中 3 条算「你发布的」,所以直接点就能把流程走完。
两个给测试人员准备的开关(在地址栏里改路由即可):
| 想看什么 | 打开这个地址 |
|---|---|
| 恢复成内置示例数据 | index.html#/home?reset=1 |
| 首页加载失败的样子 | index.html#/home?fail=1 |
| 搜索结果加载失败 | index.html#/search?kw=雨伞&fail=1 |
| 详情页加载失败 | index.html#/detail/itm_seed_1?fail=1 |
想跑单元测试的话(可选,需要本机有 Node.js 18 及以上版本):
npm test
也不需要 npm install,原因见下一节。

八、单元测试
8.1 我们用的测试工具,以及是怎么学的
最终用的是 Node.js 自带的 node:test。
作业附录推荐的是 Mocha(廖雪峰和阮一峰的教程举的都是 Mocha 的例子),我们一开始也打算照做。动手装的时候发现一个问题:Mocha 需要 npm install 装依赖,而这个项目从第一天起就定了一个目标——别人下载下来双击 index.html 就能用,不需要装任何东西。为了测试往仓库里塞一个 node_modules,跟这个目标是矛盾的。
于是换成了 Node 18 以上版本内置的测试框架 node:test。它和 Mocha 的写法几乎一样,都是 describe / it / assert 三件套,但零依赖,node --test 或者 npm test 直接就能跑起来。
自己整理的一份简易教程(其实主要是我们踩过的坑):
-
准备:确认
node -v在 18 以上,不需要装任何东西。 -
写用例:用
describe分组、用it写单条用例、用assert断言结果。一组相关的用例放在同一个describe里,名字写清楚在测什么。 -
运行:
node tests/model.test.js。它会逐条打印 ✔ / ✖,最后给出pass和fail的条数。 -
被测代码要能被 require:这是最容易卡住的地方。
model.js在浏览器里是普通<script>,在 Node 里要能被require,所以文件头用了一段 UMD 包装:(function (root, factory) { if (typeof module === 'object' && module.exports) { module.exports = factory(); // Node 环境:可以被 require } else { root.LFModel = factory(); // 浏览器环境:挂到 window 上 } })(typeof self !== 'undefined' ? self : this, function () { /* ... 真正的代码 ... */ return { validateItem: validateItem /* , ... */ }; }); -
依赖浏览器的代码要多想一步:
store.js用了localStorage,而 Node 里没有这个东西。我们写了一个内存版的假localStorage顶替它(就是一个普通对象加上getItem / setItem),这样存取层也能在 Node 里直接测,不用开浏览器。 -
别只测顺利的情况:这是写完之后最有体会的一点,见 8.3。
8.2 测了哪些函数
一共 81 个用例,分成 15 组:
| 测试文件 | 分组 | 主要测的函数 |
|---|---|---|
tests/model.test.js(51 个) |
表单校验 | validateItem、checkContact |
| 新建条目 | createItem |
|
| 关键词匹配 | matchKeyword、isBlankKeyword |
|
| 组合筛选与排序 | filterItems、sortItems |
|
| 状态流转 | markClosed、reopenItem、isMine、statusLabel |
|
| 展示格式化 | formatDate、relativeTime、contactText、stats |
|
| 序列化与容错 | normalize、deserialize、serialize |
|
| 附加特点 | placeTagsOf、hasOverlap、relatedItems、pushRecent |
|
tests/store.test.js(30 个) |
初始化 | seedIfEmpty、reset |
| 增删查 | create、get、query、placeTags |
|
| 修改与状态 | update、close、reopen |
|
| 删除信息 | remove |
|
| 最近搜索 | recent、addRecent、clearRecent |
|
| 脏数据容错 | 坏 JSON、非法条目 | |
| 异步读取外壳 | fetchItems、fetchItem、fetchMine |
举一个例子,说明我们的用例长什么样:
describe('状态流转 —— markClosed / reopenItem', () => {
it('标记已找到:写入状态、结束原因和结束时间', () => {
const before = item({ id: 'a', type: 'lost' });
const after = M.markClosed(before, 1791158400000);
assert.equal(after.status, 'closed');
assert.equal(after.closedReason, '已找到');
assert.equal(after.closedAt, 1791158400000);
assert.equal(before.status, 'active', '不能改到原对象'); // 关键:确认没有副作用
});
it('已经结束的条目再标记一次:不动 closedAt', () => {
const closed = M.markClosed(item({ id: 'a' }), 111);
const again = M.markClosed(closed, 999);
assert.equal(again.closedAt, 111); // 防重复标记
});
it('撤销标记:恢复进行中并清空结束信息', () => {
const back = M.reopenItem(M.markClosed(item({ id: 'a' }), 111));
assert.equal(back.status, 'active');
assert.equal(back.closedReason, '');
assert.equal(back.closedAt, 0);
});
});
运行结果(完整输出约 130 行,这里摘一段;下面每一条用例的名字、通过状态和耗时都是 npm test 跑出来的真实输出):
$ npm test
▶ validateItem —— 必填项与格式校验
✔ 空表单:8 个必填项全部报错,ok 为 false (0.96ms)
✔ 物品名称只有空格:等同于没填 (2.05ms)
✔ 物品名称过短或过长:给出长度提示 (0.33ms)
✔ 日期晚于今天:拦下来 (0.13ms)
✔ 手机号不是 11 位:拦下来 (0.12ms)
✔ 合法表单:ok 为 true,errors 为空 (0.53ms)
✔ validateItem —— 必填项与格式校验 (6.84ms)
▶ filterItems —— 组合筛选
✔ 默认只返回进行中的信息,且按日期倒序 (0.34ms)
✔ type 筛选 (0.16ms)
✔ place 按子串筛选 (0.09ms)
✔ status=closed 只看已结束,status=all 看全部 (0.11ms)
✔ 查询条件为空、列表为空都不报错 (0.14ms)
✔ filterItems —— 组合筛选 (0.98ms)
▶ 状态流转 —— markClosed / reopenItem / isMine / statusLabel
✔ 标记已找到:写入状态、结束原因和结束时间 (0.45ms)
✔ 已经结束的条目再标记一次:不动 closedAt (0.15ms)
✔ 撤销标记:恢复进行中并清空结束信息 (0.16ms)
✔ 状态流转 —— markClosed / reopenItem / isMine / statusLabel (0.71ms)
……(中间几组省略)
ℹ tests 51 ℹ suites 8 ℹ pass 51 ℹ fail 0
--- tests/store.test.js ---
✔ 初始化与内置示例数据 (4.59ms)
✔ 新增与查询 (1.54ms)
✔ 修改与状态流转 (1.32ms)
✔ 删除信息 (1.06ms)
✔ 最近搜索 (0.84ms)
✔ 脏数据容错 (0.89ms)
✔ 异步读取外壳 (1.16s)
ℹ tests 30 ℹ suites 7 ℹ pass 30 ℹ fail 0
这里没有放终端截图,是因为测试输出的每一行本来就是可以复制的文本:贴成代码块比截图更清楚,助教也能直接对照仓库里的
tests/复现。
8.3 测试数据是怎么构造的,怎么考虑各种情况
第一,先顺着「正常一次」把正向用例写出来,例如一份完整的合法表单应该校验通过:
function validForm(over) {
return Object.assign({
type: 'lost', title: '校园卡', category: '证件', date: '2026-01-01',
place: '一教三楼走廊', desc: '蓝色卡套,卡角有一道折痕,姓名首字母是 L',
contactType: 'wechat', contact: 'zzl_314'
}, over || {});
}
这个 validForm 是整套用例的基础:要测哪个字段的边界,就只覆盖那一个字段,其余字段保持合法。这样一条用例挂掉时,能立刻知道是哪条规则出了问题,而不是十几个规则一起报错。
第二,用白盒的思路逐条找分支。 我们会对着代码看「这里一共有几种走法」:
- 例如
validateItem里,名称那一项有三种走法:空 / 长度不对 / 合法,那就写三条用例。 - 日期那一项因为还要判断格式,多了「格式不对」和「晚于今天」两条。
- 联系方式有四种类型,每种都有各自的格式规则,所以四种都单独测。
第三,专挑「边界」和「不合理输入」。 这部分最有意思,因为它真的抓出了 bug:
| 我们故意输入的东西 | 期望行为 | 结果 |
|---|---|---|
名称只输空格 ' ' |
等同于没填 | 通过 |
| 搜索框只输空格 | 不能把全部信息列出来 | 通过(早期版本会列出全部,已修) |
搜索关键词 %、_ |
按普通字符处理,不能当通配符把全部匹配出来 | 通过 |
localStorage 里存了坏 JSON |
读到空列表,不能崩 | 通过 |
数组里混进 null、纯数字 |
跳过非法项,保留合法的 | 通过 |
| 物品名称只有一个字(「伞」) | 应该能和「雨伞」匹配上 | 失败,已修 |
| 地点都带「二楼」的两条不同信息 | 不该被当成相关推荐 | 失败,已修 |
后面两条是这次测试最实在的收获:
- 单字标题匹配失败:判断两个名称有没有共同词时,我们是把它切成 2 字片段来比对的。可是「伞」只有一个字,切不出片段,就永远匹配不上「雨伞」。补了一条「包含关系」的兜底判断才修好。这种问题肉眼测的时候根本不会想到——谁会拿一个字当物品名呢。
- 相关推荐误报:「图书馆二楼阅览室」和「三区食堂二楼」都含「二楼」,于是被判定成地点相关。后来把门槛从「得分大于 0」提高到「得分 ≥ 2」,光靠地点沾一点边不算数。
第四,想想「将来测试的人会怎么刁难我们」。 老师的原话是「你如何考虑将来测试人员的刁难」,我们的思路分三类:
- 乱输入:空格、超长文本、特殊符号、全是标点的关键词。
- 乱操作:连点两次发布、点开别人的信息想找标记按钮、直接改地址栏里的 id(我们用「这条信息不存在或已被删除」的空状态兜住)、把自己的信息标记完再撤销。
- 乱数据:手动把
localStorage改坏、改成一个不是数组的东西、往数组里塞非法条目。这些都用try / catch加normalize()过滤兜住了,测试里也专门覆盖。
最后说一句我们觉得最值的地方。 写测试之前,我们以为测试就是「证明自己写对了」。写完之后发现它最有价值的时刻恰恰是用例红了的时候——上面那两个 bug 都不是我们盯着代码看出来的,是用例跑出来的。老师上次说「页面设计与实际交互之间还需要更充分的检查」,现在能理解那句话了:光靠人眼走一遍,只能覆盖到「正常的那些情况」。
九、GitHub 签入记录
仓库地址:https://github.com/zzl3141/102401231-102401228(Public,助教可以直接 clone)
协作方式:赵紫龙建仓库,在 main 上按功能分阶段提交;林彦翔 fork 之后在自己的分支上开发,做完提 Pull Request,由赵紫龙 review 再合并。这样两个人都留下了可见的工作记录,也符合作业里「一位同学新建仓库,另一位 fork 该项目,有进展时 pull request」的要求。
我们没有把代码攒到最后一次性提交,而是一个功能一次 commit,顺序和博客第五节的实现思路完全对应:
(赵紫龙在 main 上的提交)
01517d2 feat: 实现数据层(校验 / 筛选 / 状态流转 + localStorage 存取)
bf30dd9 chore: 搭建项目骨架(入口、路由、样式、页面占位与图片素材)
96af41c feat: 完成首页列表、分类筛选与加载状态
3920543 feat: 完成搜索页关键词匹配与空状态
fa225ba feat: 完成信息详情页与一键复制联系方式
324af6c feat: 完成发布与编辑表单(校验、防重复提交、图片选填)
dd3cd97 feat: 完成我的发布与状态闭环(标记、二次确认、撤销)
3854f09 fix: 回到首页时重置筛选,避免漏看刚发布的信息
e9940e4 feat: 增加恢复示例数据的走查开关(#/home?reset=1)
e601d52 feat: 完成附加特点(按地点筛选、最近搜索、相关推荐)
64398c5 test: 补充 77 个单元测试用例(node:test,零依赖)
772cf89 docs: README 补充在线预览地址(GitHub Pages)
(林彦翔 fork 后通过 Pull Request 合并进来)
fbf992b feat: 我的发布增加删除信息与二次确认 ← PR #1
944de15 style: 补齐触摸按压反馈与空图片占位 ← PR #2
4861210 style: 详情页安全区适配、提示动效与无障碍细节 ← PR #2
1ce4517 Merge pull request #1
79cff0f Merge pull request #2
(评审 PR 时发现删除功能用到的 store.remove 没有测试覆盖,补上)
f9afeaf test: 补充删除信息的用例
commit message 统一用 类型: 做了什么 的格式(feat / fix / docs / chore / test),目的是让人不用点开代码就知道这次改了什么。其中 3854f09 那条 fix 是一处真实的返工:走查时发现首页筛选状态会残留,导致刚发布的信息看不见,单独作为一条提交记下来。
Review 的时候发现了什么。 两个 PR 都改了 css/components.css,但改的是不同区域(一个加按钮样式、一个加移动端适配),所以没有冲突。真正值得说的是:PR #1 新增的删除功能调用了数据层的 store.remove(),而这个函数之前一直没有测试覆盖——功能能跑,但没人验证过它删完之后「我的发布」里那条 id 会不会残留。补了 4 条用例之后发现逻辑是对的,但这条覆盖是补上才踏实的。



十、遇到的困难与解决方法
困难一:「双击就能跑」这个要求,把技术方案框死了
问题描述:我们原本打算把 JavaScript 拆成几个 ES Module 分文件写,这样每个文件职责清楚。在本地起服务测试时一切正常,但想到作业要求「其他同学下载所有文件后用 Chrome 运行 html 文件就能展现预期结果」,就用双击的方式试了一次——页面全白,控制台报的是模块加载被拦下的错误。
做过哪些尝试:
- 先怀疑是文件路径写错,检查了一遍相对路径,没有问题。
- 查资料后确认:
file://协议下浏览器不允许加载 ES Module,因为模块请求会被当成跨源请求拦掉。这不是代码写错,是协议限制。 - 改用最原始的方案:普通
<script>标签按顺序加载,每个文件把自己的能力挂到一个全局对象上(window.LFModel、window.LFStore…)。 - 顺带做了一个检查:全站不能出现
fetch、XMLHttpRequest和绝对路径。数据干脆内联在store.js里,不去读 JSON 文件。
是否解决:解决了。现在双击 index.html 就能跑,加载顺序在 index.html 里写了注释固定下来。
有何收获:以前觉得「怎么运行」是交付时才需要考虑的事,这次发现运行方式会反过来决定代码怎么组织。需求里那句「用谷歌浏览器运行 html 文件就能展现预期结果」看着像一句客套话,实际上是一条硬约束。
困难二:首页的筛选状态会「粘」住,刚发布的信息看不见
问题描述:走查的时候发现一个很怪的路径——先在首页把筛选切到「已结束」,然后去发布页发一条新信息,再回首页,新信息不见了。看起来像发布失败,其实数据写进去了,只是首页还停在上一次的「已结束」筛选上。
做过哪些尝试:
- 第一反应是发布没写进去,检查了
localStorage,数据在,排除。 - 又想是不是首页没刷新,但首页确实重新渲染了。
- 最后定位到:筛选状态是存在模块变量里的,切换筛选不走路由,所以重新进入首页时状态被保留了下来。
是否解决:解决了。改成每次「进入」首页都回到默认视图(全部 / 全部类别 / 进行中),页面内点筛选仍然保留状态不闪屏;如果地址栏里带了筛选参数,就以参数为准。改完复测:发布后回首页立刻能看到,条数从 6 条变 7 条。
有何收获:状态的「生命周期」和状态本身一样重要。 我们之前只想了「筛选状态要不要记住」,没想过「记多久」。这个 bug 不崩溃、不报错,但会让用户以为功能坏了,比直接报错更难发现。
困难三:两个只有靠测试才能发现的匹配 bug
问题描述:写完单元测试跑第一遍时,两条用例红了,而且都不是我们预期的:
- 物品名称只有一个字「伞」时,匹配不到「雨伞」;
- 相关推荐把不相关的信息推了出来——「图书馆二楼阅览室」和「三区食堂二楼」都被判成了「地点相关」,只因为都含「二楼」。
做过哪些尝试:
- 第一个问题:我们的匹配逻辑是把中文切成 2 字片段来比对的(「雨伞」→
雨、伞两个片段)。但「伞」只有一个字,切不出片段,于是两边永远对不上。加了「包含关系」的兜底判断,一个字的标题也能匹配上更长的名称。 - 第二个问题:算了一下分数,地点那一项只值 1 分。原来的门槛是「得分大于 0 就推荐」,所以光靠地点沾边也能进来。把门槛提到「得分 ≥ 2」,也就是至少要类别相同,或者名称里有共同词。
是否解决:都解决了,两条用例现在都是绿的。
有何收获:这两个 bug 我们盯着代码看了很久都没看出来,是用例跑出来的。尤其是「伞」那一条——现实里几乎没有人会给物品起一个单字的名字,但测试用例不会替你想「这合理吗」,它只会告诉你「这里没走通」。这大概就是老师说的「想想将来测试人员会怎么刁难你」的意思。
困难四:两个人改同一份代码,怎么不互相覆盖
问题描述:这是结对编程本身带来的问题。两个人对着同一份代码改,很容易出现一种情况——一个人刚调好的地方,被另一个人改回去了,而且两个人都不确定是谁改的。
做过哪些尝试:
- 按文件划分归属,而不是按「谁有空谁改」。每个
js/ui-*.js只归一个人,model.js这种所有人都会用到的公共文件由一个人维护,另一个人要用就直接调,不复制一份。 - 在每个文件头写清楚负责人和这个文件要做什么。这样打开文件就知道该不该动它,沟通成本降到最低。
- 要改对方的文件之前先在群里说一声,避免两个人同时在改。
- 每完成一个功能就提交一次,出问题能通过 commit 记录定位到是哪一步引起的。
是否解决:这四条从第一天就定下来写进了协作说明,整个开发过程没有出现互相覆盖的情况。
有何收获:以前觉得「分工」就是分任务,现在觉得分工的一半是分文件。任务分得再清楚,两个人还是可能改到同一行代码;文件归属划清楚之后,冲突从「需要解决的问题」变成了「不会发生的问题」。
十一、评价队友
我眼中的赵紫龙
- 值得学习的地方:赵紫龙把「双击就能跑」当成了硬约束,这是我最服的一点。我们原本想用 ES Module 分文件写,是他先意识到助教双击打开走的是
file://协议、模块会被同源策略拦下,才改成普通<script>标签加全局命名空间——这个决定定了后面所有代码的组织方式。他还有个习惯特别好:把所有校验、筛选、状态流转都写成model.js里的纯函数、不碰 DOM,所以 77 个单元测试才能脱离浏览器直接用 Node 跑。跟着他的结构写,我发现自己以前习惯把逻辑和界面塞在一起,测试根本没得测。 - 需要改进的地方:他前期推进太快,把阶段 5~7(详情、发布、我的发布)都自己写完了,留给我 fork 后能做的原创空间就变小了,我只能在「删除信息」和视觉细节上做增量。另外评审 PR 时才发现
store.remove()一直没有测试覆盖,说明「提交前先自查一遍测试」这一步还能更早做。希望下次他能早点停下来,把属于我的部分真正留给我。
十二、个人总结
这次和上次画原型最大的不同是:上次只需要把界面「画出来」,这一次每个点击背后都要回答「数据从哪来、状态存在哪、点了之后会发生什么」。我主要负责「我的发布」的删除功能和移动端视觉细节,最大的感受是「能用」和「好用」之间隔着一堆看不见的小事——复制联系方式双击打开会失败、没图的卡片一片空白像坏掉、全面屏上底部按钮会被手势条挡住、骨架屏动画对开「减弱动效」的人不友好。这些每项只要几行代码,但不真正在真机环境里点一遍、试一遍,就永远发现不了。
有件事改变了我的想法:review 赵紫龙代码、以及后来他 review 我的 PR 时,我才发现「新增一个功能,顺手想想这一层有没有测试覆盖」有多重要——我加的删除功能用了 store.remove(),而这个函数之前一直没被测过,是他评审时点出来的。以前我觉得测试是「证明自己写对了」,现在才明白它最有价值的时刻是用例红了的时候。下次动手前我会先想清楚「怎么运行、怎么测」,每加一个功能就顺手补一条测试,而不是等最后补。

浙公网安备 33010602011771号