2026秋软件工程结对作业(第二次之程序实现)
校园失物招领 —— 结对作业二(福州大学 2026 软件工程实践)
作业信息
| 项 | 内容 |
|---|---|
| 结对同学博客链接 | https://www.cnblogs.com/tech-notes-101/p/23256736 |
| 本作业博客链接(两人各自提交) | https://www.cnblogs.com/JunYU111/p/23256640 |
| GitHub 仓库地址 | https://github.com/1cecream1/102401114-102401126 |
| 第一位同学 | 102401114 颜俊宇(逻辑层 / 单元测试 / 建仓与合并 / README) |
| 第二位同学 | 102401126 林世颖(页面层 / 样式 / 走查截图 / 博客排版) |
| 呈现形式 | WEB 网页(PC 端 / 移动端自适应),统一使用 Google Chrome |
| 运行方式 | 下载后双击 index.html,零依赖、零构建、无需服务器 |
班级群结对表与 GitHub 项目地址统计表已在 deadline1 前填写完成。
一、具体分工
| 成员 | 主要负责 | 具体产出 |
|---|---|---|
| 颜俊宇(102401114) | 逻辑层 + 单元测试 + 仓库管理 | js/constant.js(枚举与文案契约)、js/seed.js(24 条示例数据)、js/store.js(ItemStore 存取与发布者标识)、js/validate.js(表单校验)、js/search.js(搜索 / 筛选 / 排序)、js/status.js(状态文案)、js/reward.js(悬赏与打赏的纯函数);test/ 下 7 个测试文件 181 个用例;建仓、审 PR、合并、README |
| 林世颖(102401126) | 页面层 + 样式 + 走查 | index.html(六个视图结构)、css/style.css(主题与卡片体系、移动端自适应)、js/app.js(渲染与事件绑定);附加特点的界面实现;浏览器走查与截图;博客撰写与排版 |
两人共同完成:需求讨论与功能范围界定、接口约定与联调走查、PSP 表格填写、博客互审、互相评价。
这样分工的理由:逻辑层 / 页面层各一人是班级里最主流的切法,接口先冻结,两人并行互不阻塞。甲先交出逻辑层接口约定(及可运行的实现)之后,乙当天就能把页面跑通;反过来,甲要写单元测试,被测对象正好就是自己写的逻辑层,测试与实现同源,返工最少。
唯一耦合点:js/constant.js(双方共用的枚举与文案字典)与逻辑层的方法签名。这两处先冻结,之后两人分别改各自的文件,全程没有出现过一次合并冲突。
二、PSP 表格
单位:分钟,为每人投入。预估时两人根据同一份《开发方案》的阶段拆解独立填写,数值恰好一致;实际耗时按各自开发记录回填。
| PSP2.1 | Personal Software Process Stages | 预估耗时 | 实际耗时(甲) |
|---|---|---|---|
| Planning | 计划 | 10 | 5 |
| Estimate | 估计这个任务需要多少时间 | 20 | 15 |
| Development | 开发 | ||
| Analysis | 需求分析(包括学习新技术) | 100 | 130 |
| Design Spec | 生成设计文档 | 60 | 70 |
| Design Review | 设计复审 | 40 | 35 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 20 | 20 |
| Design | 具体设计 | 60 | 70 |
| Coding | 具体编码 | 300 | 340 |
| Code Review | 代码复审 | 50 | 55 |
| Test | 测试(自我测试,修改代码,提交修改) | 140 | 180 |
| Reporting | 报告 | ||
| Test Report | 测试报告 | 50 | 55 |
| Size Measurement | 计算工作量 | 15 | 15 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 30 | 30 |
| 合计 | 795 | 1020 |
实际与预估的差异说明(这部分比数字本身更有意义):
- Analysis 超出预估(100 → 130 / 120):多出来的时间主要花在「用无头 Chrome 实测
file://环境」上——我们需要先确认双击index.html时localStorage、crypto.randomUUID、navigator.clipboard到底能不能用,这决定了技术选型能否成立。最后实测结论是三个都能用(isSecureContext === true),选型因此站得住。 - Coding 超出预估(300 → 340 / 360):页面层的
app.js要同时处理六个视图的路由、三种列表渲染、双态表单、四处错误提示位,比预想的多;逻辑层则多写了大量「非法输入兜底」的分支。 - Test 超出预估(140 → 180 / 165):除了设计并写完 181 个单元测试用例,还额外写了一套浏览器端到端自检脚本(
_verify/verify_app.py,88 项断言,用无头 Chrome 真实点击),这一层是预估时没有充分考虑的。 - 合计甲 1015 分钟、乙 1000 分钟,都比预估(785)多出不少,主要都花在「验证环境」「编码返工」和「补测试」上——恰好是这几件事后来帮我们省下了更多的时间(见第八节)。
三、解题思路描述与设计实现说明
3.1 代码实现思路(文字描述)
(1)技术选型:为什么是「纯静态 + localStorage」
作业 2.5 条的原话是「下载你的所有文件后,用谷歌浏览器运行 html 文件就能展现预期结果」。这一条直接否掉了需要构建步骤的方案——引入 Vue / React 就要打包,「双击 html 跑起来」就不再成立。所以技术栈定为 HTML + CSS + 原生 JavaScript,零依赖、零构建、数据用 localStorage 持久化。
数据放前端还有一个附带好处:逻辑层不依赖任何环境,可以被 Node 直接 require,单元测试才写得起来。反过来如果做了后端,「测试要连数据库」会立刻把整个测试计划拖垮。
(2)分层:三层,单向依赖

- 页面层
js/app.js:视图路由、DOM 渲染、事件绑定。只调用逻辑层,不直接碰localStorage。 - 逻辑层
js/下 7 个模块:Constant(枚举 / 文案 / 字数上限契约)、Seed(示例数据)、Reward(悬赏与打赏的读写统计)、ItemStore(存取 + 发布者标识)、Validate(表单校验)、Search(搜索 / 筛选 / 排序)、Status(状态文案)。完全不碰 DOM。 - 数据层
localStorage:只由ItemStore读写。
这条「页面层不越层」的纪律是刻意坚持的:所有「什么算合法数据」「什么算筛选中」的判断都只存在于逻辑层一处,页面层只负责把结果画出来。后来做单元测试时,这层分离直接兑现成了「测试 181 个用例全部针对逻辑层,不需要 mock DOM」。
(3)一条信息的完整旅程
以「发布一条寻物信息 → 别人搜到 → 联系我 → 我标记已找到」为例:
- 用户在发布页填表,点「立即发布」;
app.js把表单值收成一个普通对象data(注意:此时它还是不可信的原始输入)。 Validate.validateItem(data)校验,返回{ valid, errors }。valid === false就按errors的键逐项标红,流程到此为止。- 通过后
ItemStore.save(data):自动补全id(时间戳 + 自增序号 + 随机串)、ownerId、status = 'open'、views = 0、三个时间戳,然后unshift到列表并写回localStorage。 - 首页再次渲染时,
Search.filterItems(ItemStore.getAll(), filters)按当前 tab / 筛选 / 排序返回新数组,页面层只负责把它画成卡片。搜索页和首页筛选走的是同一个函数,所以两处的匹配规则永远一致。 - 别人点卡片进详情页,浏览量 +1(
update(id, { views }));联系方式默认显示●●●●●●●●,点「查看联系方式」才明文显示,再点「复制联系方式」直接拿走。 - 发布者本人在「我的发布」里点「标记为已找到」,
update(id, { status: 'done' });closedAt由update自动补上,卡片和详情页的状态标签同步变成「已找到」。
(4)三个关键的工程决策
① 模块「双导出」——决定单元测试能不能与线上同源。
七个模块统一写成:
(function (root) {
const ItemStore = { /* ...真实实现... */ };
if (typeof module !== 'undefined' && module.exports) module.exports = ItemStore; // Node / Mocha
else root.ItemStore = ItemStore; // 浏览器 window
})(typeof window !== 'undefined' ? window : globalThis);
于是测试里 require('../js/store.js') 拿到的是页面运行时用的同一份代码。这不是为了「方便测试」才补的写法,而是从一开始就按「同一份实现、两种加载方式」设计的——否则测试很容易退化成「为测试另抄一套实现」,那样测过了也不能说明线上是对的。
② 命名铁律:window 上不能挂小写的通用名。
这是往届踩过、我们提前实测避开的坑(详见第八节问题 1)。结论是模块名一律大写开头:Constant / Seed / Reward / ItemStore / Validate / Search / Status;存取模块刻意叫 ItemStore 而不是 Storage(window.Storage 是浏览器内置构造函数)。每个模块头部还加了依赖守卫,顺序错了直接抛异常,不许静默失败:
const C = (typeof module !== 'undefined' && module.exports) ? require('./constant.js') : root.Constant;
if (!C) throw new Error('[store.js] constant.js 未加载:请把 constant.js 放在本文件之前引入');
③ 时间要存两份:给人看的 lostTime 和给排序用的 lostAt。
一开始想直接用表单里那个中文时间(如「9月25日 下午」)排序,很快发现是错的——按字典序 '9月6日' 大于 '10月8日',但实际前者更早。所以数据里拆成两个字段:lostTime(中文文本,只负责展示)与 lostAt(时间戳,只负责排序),排序时若 lostAt 缺失则回落到 createdAt。同一类问题在「排序」一节还有更详细的说明(4.2)。
顺带说明:发布表单最初用的是自由文本时间(「如:9月25日 下午」),新数据只能拿「此刻」近似
lostAt,「最近丢失」因此退化。后来把该字段换成了<input type="datetime-local">,展示文本与排序时间戳由同一个输入派生,两个字段天然一致——见 4.5 问题 3。
(5)身份方案:不做登录,用 ownerId 区分「我发的」
作业已豁免实名认证,我们也不引入账号体系,而是首次访问时用 crypto.randomUUID() 生成一个发布者标识存在 localStorage,此后每条信息都带上它,ItemStore.isOwner(item) 由此判断「这条是不是我发的」,只有本人才能看到「标记已找到 / 已归还」「撤销完结」「删除」这三个按钮。
这是我们刻意的简化,也如实说明它的边界:换浏览器、换设备、清空浏览器数据后,ownerId 会变,此时看不到自己以前的发布。它的价值在于——演示时无需注册登录就能完整体验「只有发布者能改状态」这条权限逻辑,而这正是作业正文要求的功能点。
3.2 关键实现的流程图 / 数据流图
图 1(见 3.1) 是整体架构与数据流;下面三张分别对应核心业务闭环、状态流转、以及容错实现。
(1)核心业务流程图——覆盖作业正文要求的「发布信息 — 浏览 / 搜索 — 查看详情 — 联系发布者 — 更新状态」全链路,三条泳道对应三类使用者动作:

(2)状态流转图——status 只有 open / done 两个值,「已找到 / 已归还」是同一状态的两种展示文案,由类型决定:

(3)存储读取与自愈流程——这是本项目容错设计的核心,也是我在逻辑层里最满意的部分:

这三张图对应到代码就是:app.js(页面层渲染与事件)、Status.getStatusText(文案)、ItemStore.ensure / pickStorage(存储),三者之间只通过「数组」和「字符串」这两种最简单的数据类型通信。
3.3 重要代码片段与解释
片段一:ItemStore 的存储探测与自愈(js/store.js)
// 真正可用的 localStorage 才用它:隐私模式、file:// 被禁用等情况会抛异常,一律降级
function pickStorage() {
try {
const ls = root.localStorage;
if (!ls) return createMemoryStorage();
const probe = '__campus_lf_probe__';
ls.setItem(probe, '1'); // 不只判断「存不存在」,而是真的写一次
if (ls.getItem(probe) !== '1') return createMemoryStorage();
ls.removeItem(probe);
return ls;
} catch (e) {
return createMemoryStorage(); // 写不进去 / 被禁用 → 内存替身
}
}
function ensure() {
const raw = readKey(C.STORAGE_KEY);
if (raw !== null && raw !== undefined && raw !== '') {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
} catch (e) { /* JSON 损坏 → 落到下面重新灌示例数据,自愈 */ }
}
return writeSeed(); // 首次访问,或者数据已经损坏
}
为什么这段值得贴:
pickStorage()用「真的写一次」代替「判断对象存不存在」。 隐私模式、被策略禁用的浏览器里localStorage对象是存在的,只是setItem会抛异常——只判断if (localStorage)会在这些环境里埋雷。写一个探针键、读回来确认、再删掉,才能真正确认它可用。ensure()把「首次访问」和「数据损坏」合并到同一条恢复路径上。 页面层永远只会拿到「一个数组」,永远不需要处理null或异常;localStorage里的内容被别的东西写坏时,页面会自动重新灌入示例数据,而不是白屏。- 降级是「行为一致、只是不持久化」,不是「功能消失」。 内存替身的
getItem/setItem/removeItem与localStorage接口完全一致,所以上层的ItemStore一行都不用改。这也正是单元测试能在 Node 里直接跑的原因——Node 里没有localStorage,于是自动走了这条降级路径。
片段二:校验结果的「按字段归类」(js/validate.js)
function validateItem(data) {
const errors = {};
const d = (data && typeof data === 'object') ? data : {}; // 传 null / 字符串也不抛异常
if (!C.isValidType(d.type)) errors.type = '请选择「我丢了东西」或「我捡到东西」';
const name = text(d.name);
if (!name) errors.name = '请填写物品名称';
else if (name.length > C.LIMITS.name) errors.name = tooLong('物品名称', name, C.LIMITS.name);
if (!C.isValidCategory(d.category)) errors.category = '请选择物品分类';
if (!C.isValidPlace(d.place)) errors.place = '请选择' + C.getPostTab(d.type).placeLabel;
const contact = text(d.contact);
if (!contact) errors.contact = '请填写联系方式,方便失主或拾获者联系你';
else if (contact.length < C.LIMITS.contactMin) errors.contact = '联系方式至少 3 字,现在 N 字';
return { valid: Object.keys(errors).length === 0, errors: errors };
}
为什么这段值得贴:
- 校验函数不知道 DOM 的存在。 它只返回一个大对象,页面层拿着
errors.name/errors.contact各找各的提示位去渲染。所以「一次提交把所有错都标出来」是天然的——不需要页面层写任何「先查哪个字段」的逻辑。 - 提示文案是「动态」的,不是「静态的」。 「请选择丢失地点 / 请选择拾获地点」这一处,直接从
Constant.getPostTab(type).placeLabel取,所以寻物态与招领态共用同一份校验代码,文案自动跟着表单切换。 - 错误提示里带真实字数(「不超过 30 字,现在 31 字」),这个细节后来被直接写成了单元测试的断言——测试会检查提示里是否包含
31这个数字,防止有人把>顺手写成>=。 errors的键名就是页面上的字段名,这条约定让页面层可以写成循环([['#f-name','#err-name'], ...]),也让「重新输入时自动清掉该项错误态」只花了三行代码。
片段三:详情页联系方式的「三态渲染」(js/app.js)
let contactHtml;
if (state.contactRevealed) {
contactHtml =
'<div style="text-align:center;font-size:16px;margin:8px 0">' + escapeHtml(item.contact) + '</div>' +
'<button class="btn-outline" style="width:100%" onclick="copyContact(\'' + escapeHtml(item.contact) + '\')">' +
Constant.TEXT.contactCopy + '</button>';
} else {
contactHtml =
'<div class="detail-contact-masked">' + Constant.TEXT.contactMasked + '</div>' +
'<button class="btn-outline" style="width:100%" onclick="revealContact()">' +
Constant.TEXT.contactReveal + '</button>';
}
为什么这段值得贴:
- 「打码 → 显示 → 复制」三态只用了一个布尔量
state.contactRevealed表达,没有额外的 DOM 操作、没有显隐切换的中间态。重新渲染详情页时状态自然恢复,不会出现「返回列表再进来还留着上一条的明文」。 - 明文只在「用户明确点过」之后才渲染进 DOM。 列表页、搜索页的卡片里根本没有联系方式字段,也就是说没有「随手复制页面源码就能批量捞号」的路径——这也是我们在搜索模块里刻意让
contact不参与关键词匹配的原因(KEYWORD_FIELDS只有名称 / 分类 / 地点 / 描述四个字段)。 - 所有插值的文本都过
escapeHtml()。 用户输入的名称、描述、联系方式会原样插进innerHTML,不转义就等于把 XSS 的门敞开——虽然是课程作业,但这条是底线。
四、附加特点设计与展示
对应评分细则:设计的创意独到之处与意义(3 分)/实现思路(3 分)/重要代码片段与解释(2 分)/实现成果展示(2 分)。
作业正文只要求「发布信息 — 浏览/搜索 — 查看详情 — 联系发布者 — 更新状态」这条最小闭环,而评分规则里明确写了「尽情丰富内容」,并且自己举了两个例子:按物品类别、遗失或拾取地点进行筛选、一键复制联系方式。我们以这两项为必做(直接命中规则举例),又补了六项,一共 8 项附加特点。它们全部在附件项目里点得到,也可以在本节的截图里看到。
| # | 附加特点 | 与作业要求的关系 | 入口 |
|---|---|---|---|
| E1 | 分类 / 地点 / 状态组合筛选 | 正文只要求「集中浏览」,没要求筛选 | 首页三个下拉 + 类型 tab |
| E2 | 一键复制联系方式 | 正文只要求「查看联系方式」 | 详情页「复制联系方式」 |
| E3 | 三种排序(最新发布 / 最近丢失 / 最多浏览) | 正文未要求 | 首页排序栏 |
| E4 | 相似物品匹配提示 | 正文未要求 | 发布寻物信息后,成功页给出可点击入口 |
| E5 | 首页数据看板(累计 / 寻物 / 招领 / 已完结比例) | 正文未要求 | 首页统计条 |
| E6 | 信息新鲜度提示(「3 小时前发布」/ 超 30 天提示过时) | 正文未要求 | 每张卡片的时间行 |
| E7 | 24 条示例数据首次访问自动灌入 + 一键重置 | 正文未要求,但决定了「打开就能用」 | 自动灌入 / 「我的」页「恢复示例数据」按钮 |
| E8 | 悬赏与打赏(寻物帖挂悬赏、招领帖收打赏) | 正文未要求,只要求「联系发布者」 | 发布寻物帖填悬赏 / 招领帖详情页「打赏拾金不昧者」 |
说明:作业评分规则里写明「作业要求的内容不算额外特点」,所以上面每一条都刻意避开了正文点名的功能——筛选对应的是「集中浏览」,复制对应的是「查看联系方式」,都算在要求之外的加法。E8 同理:正文只要求「联系发布者」,悬赏与打赏是这条线之外、用来提高信息可信度的加法。
4.1 设计的创意独到之处与意义(3 分)
① 筛选不是「三个下拉」,而是「一个可以叠加的检索入口」。
校园失物招领的真实使用场景里,来找东西的人是带着记忆碎片来检索的:「我大概是在图书馆丢的」「应该算电子产品」。所以类型 tab(全部 / 寻物 / 招领)、分类、地点、状态这四个维度被设计成可以叠加的,而不是各筛各的装饰。实测:24 条示例数据里,叠加「寻物 + 电子产品 + 进行中」后剩 3 条,用户看到的是 3 张能直接点进详情、能直接联系的卡片。
它的意义不只是「能筛」,而是在下拉框里就把分类和地点做成了校园的封闭词表(8 个分类 / 8 个地点,写在 constant.js 里被两个人共用),用户填不出五花八门的自由文本,于是筛选才成立、关键词搜索才准。
② 联系方式「先打码、再显示、再复制」,三步各解决一个问题。
失物招领的联系方式一旦明文挂在列表上,就等于把同学的微信号交给爬虫。所以详情页默认显示 ●●●●●●●●,点「查看联系方式」才明文显示(多一次明确的用户意图),再点「复制联系方式」直接拿走——省掉手动选字复制的每一步。三步合起来:既不公开暴露隐私,也不让想联系的人被「长按选中、拖到边界、复制」这套操作劝退。
③ 让「发布」和「浏览」互相导流:相似物品提示。
发寻物信息的人最怕的一种情况是:发完之后才发现,其实早就有人捡到并发了招领。所以发布寻物信息时,系统拿刚填的分类去招领库里查一遍,命中就在发布成功页给出提示,并且这条提示本身就是一个入口:
库里有 2 条同类的招领信息,可能已被捡到,先去看看 ›
点「先去看看 ›」直接跳进那条招领帖的详情页(见 4.5 问题 2:最初它只是一条会自动消失、点不了的提示,后来补上了出口)。
这条提示的价值在于把两条本该相遇的信息提前撞上,减少的是「重复发布 → 无人应答 → 反复刷新」的无效流程。
④ 数据看板与新鲜度提示,回答「这个平台有人在用吗」。
首页顶部给出 4 个数字(本次运行:24 条累计 / 14 条寻物 / 10 条招领 / 33% 已完结),「已完结比例」尤其重要——它是这个平台是否真的在解决问题的证据;每张卡片上则给出「刚刚发布 / N 分钟前 / N 小时前 / N 天前发布」,超过 30 天的信息直接提示「发布时间已超过 30 天,信息可能已过时」,避免同学把精力花在一条早已失效的信息上。
⑤ 悬赏与打赏:「承诺」和「兑现」分别挂在两种帖子上(E8)。
失物招领里其实有两种完全不同的「给钱」动机,我们没有把它们混成一个功能:
- 悬赏挂在寻物帖上——失主在发布时说「找回后自愿兑现 ¥N」。它是一句承诺,纯展示、不落任何流水,所以界面上(首页徽章、详情页橙色行)都写明「找回后自愿兑现,仅作展示,不涉及线上支付」。
- 打赏挂在招领帖上——失主看到别人拾金不昧,在详情页点「🎁 打赏拾金不昧者」,落一条本机记录,形成一面「打赏墙」(每笔金额 + 一句留言 + 累计「共 N 次 · ¥M」)。
这样切分的好处有两个。第一,语义不重叠:承诺归承诺、兑现归兑现,同一张帖子上不会既喊悬赏又收打赏。第二,顺手挡掉一个怪逻辑:如果打赏也开放给寻物帖,就会出现「自己给自己打赏」——所以 Reward.canTip(item, isOwner) 明确要求「招领帖 + 非本人」才给出入口。
为什么是「记录」而不是「支付」:这个项目是纯静态、零后端的(4.1 节的技术选型),真实转账不可能闭环。与其做一个假的支付按钮,不如把界面上如实标注「演示:打赏仅在本机记录,不涉及真实支付」——这是诚实,不是缩水。
一个设计取舍:打赏总额由 tips 数组派生(Reward.getTipTotal(item)),而不是另存一个 tipTotal 字段。多存一个字段就多一个「改了数组忘改总额」的漂移源,而派生值永远不会自相矛盾。
总的说:这几项附加特点指向同一个目标——把「发布一条信息」这个单点动作,变成信息能被找到、能被信任、能被闭环。悬赏与打赏补的正是最后一环:让「愿意归还」这件事有正反馈。
4.2 实现思路(3 分)
总原则:附加特点不允许破坏核心闭环。 所以这 8 项里没有一项需要新增数据表或新增页面——它们要么是在已有结构上多挂一个筛选维度(E1 / E3 / E6),要么是复用逻辑层已有的纯函数(E4 直接复用 Search.filterItems、E8 复用 Constant.isValidAmount 与打赏弹层),要么是一个纯展示函数(E5 / E6)。这样做的直接好处是:附加特点的改动只落在 app.js、reward.js 与 constant.js 里,逻辑层的数据结构与原有 9 个接口一行都没动(E8 只给信息对象多加 rewardAmount / tips 两个字段),_localtest/ 的逻辑层用例始终全绿。
(1)组合筛选:把所有筛选项收敛成一个 filters 对象。
页面层不写「先按类型筛,再按分类筛」的瀑布式判断,而是把当前所有选择攒成一个对象交给同一个纯函数:
const items = Search.filterItems(all, {
type: state.homeTab, category: state.filterCategory,
place: state.filterPlace, status: state.filterStatus, sort: state.sort
});
关键词搜索走的是同一个函数(只是 filters 里只放了 keyword),所以「搜索页」和「首页筛选」永远不会出现判定不一致——规则只有一份。
(2)排序:把「按什么排」写成数据,而不是写成 if。
constant.js 里的 SORT_OPTIONS 直接声明每个排序读哪个字段、字段缺失时回落到哪个字段:
{ value: 'latest', label: '最新发布', field: 'createdAt', fallbackField: 'createdAt' },
{ value: 'lostTime', label: '最近丢失', field: 'lostAt', fallbackField: 'createdAt' },
{ value: 'hot', label: '最多浏览', field: 'views', fallbackField: 'views' }
排序函数只负责「按 opt.field 取值、一律倒序」,加一种排序只要在表里加一行。
这里踩过一个坑,值得记下来:数据里有两个时间——
lostTime(给用户看的中文,如「10月9日 下午」)和lostAt(时间戳)。一开始想直接拿lostTime排序,但中文日期按字典序比大小是错的('9月6日'大于'10月8日',而实际上前者更早)。所以展示与排序必须分成两个字段:中文文本只负责展示,时间戳只负责排序。示例数据里的lostAt是按「丢失时间比发布时间早多久」反推出来的真实丢失时刻,所以「最近丢失」和「最新发布」在演示数据上确实排出不同的顺序。
(3)一键复制:特性检测走主路径,其余全部走降级。
file:// 打开时,现代 Chrome 实测 window.isSecureContext === true,navigator.clipboard 可用,所以主路径直接用它;降级路径(临时 textarea + document.execCommand('copy'))负责兜住旧浏览器、非安全上下文和权限被拒的情况。注意降级是挂在 .catch 上的——主路径调用失败(文档未聚焦、权限被拒)时同样会落到降级,而不是只弹一句「复制失败」。这条路径最初挂错了位置,见 4.5 问题 1。
(4)相似物品提示:3 行代码,靠复用而不是新写函数。
没有必要为「找相似」写一个新方法——它本质上就是「类型 = 招领 + 分类 = 刚填的分类」的一次筛选,再把结果渲染成发布成功页上的一条可点击提示:
const similar = Search.filterItems(ItemStore.getAll(), {
type: Constant.TYPE.FOUND, category: data.category
});
// 渲染进 #similar-tip,并给一个能点的入口(openDetail 是 app.js 内部函数,
// 没挂到 window,所以只能用闭包绑 onclick)
if (similar.length > 0) {
tip.textContent = Constant.TEXT.similarTip.replace('{n}', similar.length);
const a = el('a', 'similar-link', Constant.TEXT.similarTipLink);
a.onclick = function () { openDetail(similar[0].id); };
tip.appendChild(a);
tip.classList.remove('hidden');
}
这正是 4.2(1) 那个「收敛成一个纯函数」的决定带来的复利:附加特点可以直接站在它上面。
(5)示例数据:把「打开就能用」也当成一个功能来做。
store.js 的 ensure() 在读不到数据时不只是返回空数组,而是灌入 24 条示例数据并落盘,并且把 JSON 解析失败也归到这条路径上——存储被写坏时页面会自愈,而不是白屏。另外用了一个独立的标记键 campus-lost-found:seeded 区分两种情况:「首次访问该灌数据」和「用户自己把信息全删光了」(后者不该被重新灌回来,否则删掉的东西会自己长出来)。ItemStore.reset() 则给答辩演示用:一条命令恢复 24 条示例数据。
(6)新鲜度与看板:纯函数 + 一次遍历。
时间文案由 fmtTime(ts) 一个纯函数处理(<1 分钟 / <60 分钟 / <24 小时 / <30 天 / ≥30 天 五档),阈值全部来自 Constant.TEXT,不写死在页面里;看板则是对 ItemStore.getAll() 的一次遍历,算出 4 个数字,不做缓存、不做增量——24 条数据的规模下,简单比"高效"重要。
(7)悬赏与打赏:新加一个纯函数模块,页面层一行 localStorage 都没碰(E8)。
打赏的记录读写与统计被收敛进新模块 js/reward.js(同样「浏览器挂 window / Node 用 module.exports」),页面层只做两件事:把用户选的金额交给纯函数算出一条记录,再交给 ItemStore.addTip() 落盘。
- 金额判据只有一处:悬赏与打赏共用
Constant.isValidAmount(非 NaN 的整数、1~200),页面层的档位、自定义输入、发布表单三处都从它取,不存在「这个入口能填 0、那个入口不能」。 - 累计值派生而非存储:
getTipTotal(item)现算{ count, total },所以打赏墙上「共 2 次 · ¥15」永远和明细对得上。 - 弹层复用同一套模式:打赏弹层用的是和发布表单一样的「选中态 + 行内错误提示」写法,没有为它新造一套交互。
这就是 4.2 那条「页面层不越层」的纪律在 E8 上继续生效:app.js 里没有一个字 localStorage,全部经 ItemStore.addTip()。
4.3 重要代码片段与解释(2 分)
片段一:组合筛选 + 排序(js/search.js)
// 'all' / '' / null / undefined 都表示「这一项不筛」——统一哨兵,避免到处写裸 'all'
function isAll(v) {
return v === undefined || v === null || v === '' || v === C.FILTER_ALL;
}
function filterItems(items, filters) {
const list = Array.isArray(items) ? items.slice() : []; // 先复制,不改动调用方的数组
const f = (filters && typeof filters === 'object') ? filters : {};
const keyword = lower(f.keyword).trim();
const hit = list.filter(function (item) {
if (!item || typeof item !== 'object') return false; // 脏数据跳过,不抛异常
if (!isAll(f.type) && item.type !== f.type) return false;
if (!isAll(f.category) && item.category !== f.category) return false;
if (!isAll(f.place) && item.place !== f.place) return false;
if (!isAll(f.status) && item.status !== f.status) return false;
if (!isAll(f.ownerId) && item.ownerId !== f.ownerId) return false;
if (keyword && !matchKeyword(item, keyword)) return false;
return true;
});
return sortItems(hit, f.sort); // 筛完再排,一次返回
}
// 排序也是数据驱动:排哪个字段、缺失时回落哪个字段,全写在 constant.js 的 SORT_OPTIONS 里
function sortItems(list, sort) {
const opt = C.findByValue(C.SORT_OPTIONS, sort) || C.findByValue(C.SORT_OPTIONS, C.DEFAULT_SORT);
return list.sort(function (a, b) {
const x = sortValue(a, opt.field, opt.fallbackField);
const y = sortValue(b, opt.field, opt.fallbackField);
if (x === y) return 0;
return x > y ? -1 : 1; // 一律倒序:越大越新 / 越热
});
}
为什么这段值得贴:
- 六个维度是同一条式子。 五个等值条件 + 一个关键词,全部写成
!isAll(f.x) && item.x !== f.x,所以「再加一个筛选维度」= 再加一行,不需要动结构。类型 tab、分类、地点、状态、关键词、ownerId(「我的发布」页直接复用它)全都走同一段代码。 isAll()把「没选」的四种写法统一了。 页面层不需要判断「用户到底选没选」,filters里少传一个键、传''、传null都不会误筛成空列表——这一条在_localtest/smoke_logic.js里专门有用例。- 先
slice()再排序。Array.prototype.sort()是原地排序。首页渲染时传进来的就是ItemStore.getAll()的返回值,不复制会顺手改掉调用方的数组顺序,下一次渲染的基准就乱了。 - 非法输入一律兜底。
filterItems(undefined)、数组里混null、sort传一个不存在的值,全都返回约定结果([]/ 回落到默认排序)而不是抛异常——这些兜底后来直接变成了单元测试的用例来源。
片段二:一键复制联系方式(js/app.js)
window.copyContact = function (text) {
const ok = function () { toast(Constant.TEXT.copySuccess); };
// 降级实现:临时 textarea + execCommand(剪贴板 API 不可用、或调用失败时兜底)
const legacyCopy = function () {
const 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'); ok(); }
catch (e) { toast(Constant.TEXT.copyFail); }
document.body.removeChild(ta);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(ok).catch(legacyCopy); // ← 降级挂在 catch 上
} else {
legacyCopy();
}
};
为什么这段值得贴:
- 同一个能力有两条路径,而且主路径是靠特性检测选的,不是靠"猜浏览器"。
file://下现代 Chrome 已把页面视为安全上下文(实测isSecureContext === true、读写localStorage均可用),所以两条路径里走的是前者。 - 降级用的
textarea必须脱离布局流、且不可见但可选中:用position: fixed让它不参与页面布局(否则插入/删除的瞬间页面会抖一下),用opacity: 0而不是display: none(display: none的元素无法select())。 - 降级挂在
.catch上,而不是只挂在else上。 这一条是走了弯路才定下来的:最初.catch(fail)只弹了一句「复制失败」,真正能用的execCommand降级写在else分支里,只有navigator.clipboard不存在时才轮到它。于是「API 存在但这次调用失败」的情况下,既没复制成功、也没去试那条写好的降级路径。过程见 4.5 问题 1。
片段三:打赏累计值派生而非存储(js/reward.js)
// 累计:条数与总额都由数组现算;amount 不是有限数字的记录计入条数但不计入金额
function getTipTotal(item) {
const list = getTipList(item);
let total = 0;
for (let i = 0; i < list.length; i++) {
if (typeof list[i].amount === 'number' && isFinite(list[i].amount)) {
total += list[i].amount;
}
}
return { count: list.length, total: total };
}
// 打赏只出现在「招领帖」且「不是自己发的」详情页
// —— 寻物帖走「悬赏」那条线,自己给自己打赏没有意义
function canTip(item, isOwner) {
return !!item && item.type === C.TYPE.FOUND && isOwner !== true;
}
为什么这段值得贴:
- 不存冗余字段。 打赏总额如果单独存一个
tipTotal,就多了一处「改了tips数组、忘了改总额」的漂移可能;现算虽然每次都遍历一遍,但在这个数据规模下代价可以忽略,换来的是永远不可能对不上。 - 入口判定是一个显式函数,而不是散在页面里的
if。canTip(item, isOwner)同时服务三处:详情页要不要画按钮、首页要不要显示打赏次数徽章、「我的发布」要不要把这条算进「收获打赏」。规则一处写、三处用——和 4.2(1) 里筛选函数的做法是同一个思路。 - 脏数据兜底。
amount被别的东西改成字符串时,记录仍计入条数但不计入金额,不会因为一个'10'把整面墙的合计变成NaN。逻辑层的每条兜底都配了单元测试(test/reward.test.js,27 个用例)。
4.4 实现成果展示(2 分)
下面 9 张图由自检脚本 _verify/shoot_extras.py 一次性跑出来(headless Chrome 真实渲染 + 真实点击,不是设计稿),脚本还会把每一步的数据打在终端里,图上的文字和终端里的数字是同一份。
① 组合筛选:类型「寻物」+ 分类「电子产品」+ 状态「进行中」→ 24 条中剩 3 条
三个条件同时生效,下拉框和 tab 上都能看到当前的选择;顶部统计条与筛选无关(它是全库数据),所以仍显示 24 / 14 / 10 / 33%。

② 排序切换:「最多浏览」
切到「最多浏览」后,排在最前的是浏览量最高的一条(蓝牙耳机,👁 88 次),而它在「最新发布」里并不排在第一位——说明排序真的换了维度。

③ 一键复制联系方式
详情页联系方式明文显示,点「复制联系方式」后弹出「联系方式已复制,去联系 TA 吧」。

④ 相似物品匹配提示
发布一条「寻物·日用品」,系统在招领库里查到 2 条同类信息,于是在发布成功页给出提示:「库里有 2 条同类的招领信息,可能已被捡到,先去看看 ›」。这个「先去看看 ›」是可以点的,点下去直接进入那条招领帖的详情页——不再是开局那种「有提示、点不了、1.8 秒后自己消失」的 toast(对比见 4.5 问题 2)。

⑤ 搜索空态 + 热门词
搜「航空母舰」→ 0 条结果,页面给出空态引导(而不是一片空白),上方是 6 个热门搜索词,点一下直接搜。

⑥ 信息新鲜度与过时提示
同一条卡片上,「刚刚发布 / N 分钟前 / N 小时前 / N 天前」是活的;超过 30 天的信息(这条是 33 天前发布的「宿舍钥匙」)直接提示「发布时间已超过 30 天,信息可能已过时」。

⑦ 悬赏:只挂在寻物帖上
点开一条带悬赏的寻物帖(「保温水杯」),详情页给出橙色悬赏行「🎁 悬赏 ¥10,找回后自愿兑现」。同一张页面上没有打赏入口——悬赏归悬赏、打赏归打赏,语义不重叠(见 4.1 ⑤)。

⑧ 打赏:只挂在别人的招领帖上
点开一条他人发布的招领帖(「篮球」),详情页出现「🎁 打赏拾金不昧者」按钮 + 已收到的打赏墙;点按钮后弹层从底部升起,4 个固定档位(¥2 / ¥5 / ¥10 / ¥20)默认选中 ¥2,下方是自定义金额、留言框和一句「演示:打赏仅在本机记录,不涉及真实支付」。

⑨ 一键重置的界面入口:把「答辩演示」也当成一个功能
ItemStore.reset() 早就实现了,但最初只能在控制台调用。现在「我的发布」页底部有一个「恢复示例数据」按钮,点击后二次确认(「将清空本机全部数据并恢复 24 条示例信息,确定继续吗?」),确认后数据回到 24 条并弹出成功提示——演示前不用再开控制台。

复现方式(在项目同级目录
_verify/下):python shoot_extras.py脚本开头会先调用
ItemStore.reset()把数据恢复成 24 条示例数据,所以每次跑出来的图都是干净可比的;结束时打印筛选条数、复制 toast 文案、排序首条的浏览量等真实数据。
4.5 自查发现的问题与修复(诚实记录)
写这一节时我逐项点了自己实现的功能,抓到三个问题。它们在截图里看不出来,是"点下去"才暴露的,所以我认为值得原样写进博客。
后来(写完博客初稿、又补完悬赏打赏那批功能之后)我们把这三条实际修掉了,并且每一条都补了对应的端到端断言(_verify/verify_app.py 从 78 项加到 88 项)。下面每一条都保留「当时是怎么发现的」,再写「最后怎么修的」——发现问题的那部分才是真正值钱的经验。
问题 1:复制的降级路径挂错了位置(真 bug)
- 现象:用脚本自动点击「复制联系方式」时,toast 弹的是「复制失败,请手动选中后复制」;而同一个页面里
window.isSecureContext是true、navigator.clipboard也存在——特性检测是通过的,调用却失败了(无头环境下文档未聚焦 / 权限被拒)。 - 原因:
.catch(fail)只负责弹一句"复制失败",真正能用的execCommand降级路径写在else分支里,只有navigator.clipboard不存在时才会走。于是"API 存在但这次调用失败"的情况下,代码既没有复制成功,也没有去试那条明明写好的降级路径。 - 修法:把降级抽成一个函数,
if的两个分支和catch都指向它:
window.copyContact = function (text) {
const ok = function () { toast(Constant.TEXT.copySuccess); };
const legacyCopy = function () { // 临时 textarea + execCommand
const 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'); ok(); }
catch (e) { toast(Constant.TEXT.copyFail); }
document.body.removeChild(ta);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(ok).catch(legacyCopy); // ← 挂到 catch 上
} else {
legacyCopy();
}
};
- 状态:✅ 已修复(
js/app.js)。端到端脚本里补了「把navigator.clipboard.writeText改成必定 reject,再调用copyContact」的用例,断言降级路径确实被执行、且 toast 是成功文案而不是失败文案。 - 收获:特性检测只能说明"这个 API 存在",不能说明"这次调用会成功"。 凡是写了降级路径的功能,降级应该挂在失败回调上,而不是挂在
if上。这个 bug 是靠"用脚本真的去点一下"发现的——人在浏览器里手点,因为文档处于聚焦状态,是复现不出来的。
问题 2:相似物品提示只有「提示」,没有「出口」。
- 现象:文案写的是「可能已被捡到,先去看看?」,但它是一条 1.8 秒后自动消失的 toast,点不了;同时
index.html里那个<div class="similar-tip hidden" id="similar-tip">(样式已经在style.css里写好)从头到尾没有被用上——说明当时的想法是"内联展示",最后落成了 toast。提示给出了行动建议,却没有给出行动本身。 - 修法:把
#similar-tip真正用起来,并给一个能点的链接。这里有两个细节:- 位置要在发布成功页。发布成功后页面会切到「发布成功」视图,如果提示留在发布表单里,用户根本看不到它;
- 不能用
onclick属性。openDetail是app.js内部的函数、没有挂到window上,在 HTML 属性里写onclick="openDetail(...)"会因为找不到全局函数而静默失败(不报错、点了没反应——比报错更难查)。正确做法是用addEventListener/ 闭包直接调用:
const similar = Search.filterItems(ItemStore.getAll(), {
type: Constant.TYPE.FOUND, category: data.category
});
const tip = document.querySelector('#similar-tip');
if (similar.length) {
tip.textContent = Constant.TEXT.similarTip.replace('{n}', similar.length);
const a = el('a', 'similar-link', Constant.TEXT.similarTipLink);
a.onclick = function () { openDetail(similar[0].id); }; // ← 闭包,不依赖全局函数
tip.appendChild(a);
tip.classList.remove('hidden');
}
- 状态:✅ 已修复(
index.html/js/app.js/css/style.css)。端到端脚本追加了三条断言:提示在成功页可见、里面确实有一个<a>、点它之后详情页标题等于那条招领帖的名字。 - 收获:给出了建议,就要同时给出执行建议的入口。 另外「内部函数不要通过 HTML 属性调用」这条也值得记——这类错误不会报错,只会让点击悄无声息地失效。
问题 3:「最近丢失」排序对新发布的信息会退化成「最新发布」。
- 现象:示例数据里的
lostAt是按「丢失时间比发布时间早多久」反推的时间戳,所以排序有区分度;但新发布时,发布页的时间字段是一个自由文本输入框(如:9月25日 下午),app.js只能拿当前时刻近似:data.lostAt = Date.now()。也就是说,用户自己发的那几条信息,「最近丢失」实际上等于按发布时间排——排序里出现了一个只有对示例数据才成立的假象。 - 修法:把时间字段换成
<input type="datetime-local">,让两个字段由同一个输入派生:lostAt = new Date(value).getTime()(给排序用,是用户真正选的丢失时刻);lostTime由一个纯函数fmtLostTime(value)把该时刻格式化回中文(10月9日 傍晚),给人看。时段按小时分档(凌晨 / 早上 / 上午 / 中午 / 下午 / 傍晚 / 晚上),刻意和示例数据用同一套措辞——否则新发的那几条在列表里会显得和示例数据不是一家。
// 展示与排序由同一个输入派生,天然一致,也不需要写任何中文日期解析
const timeRaw = $('#f-time').value; // '2026-10-09T18:00' 或 ''
data.lostTime = fmtLostTime(timeRaw); // '10月9日 傍晚'(给人看)
const lostTs = new Date(timeRaw).getTime();
data.lostAt = isFinite(lostTs) ? lostTs : Date.now(); // 时间戳(给排序用)
- 不动的部分:
seed.js里示例数据的lostTime(中文文本)与lostAt(反推的时间戳)保持原样——它们本来就是自洽的,改了反而会让演示数据失真。validate.js也不用改:时间字段仍然是必填,没填时value是空串,原有校验照常拦下。 - 状态:✅ 已修复(
index.html/js/app.js)。端到端脚本追加了两条断言:新发布信息的lostAt必须等于所选时刻的时间戳、lostTime必须是格式化后的中文。(顺带说明:_verify/verify_app.py里原来给时间字段赋的是中文字符串,改成datetime-local后必须改赋'2026-10-09T18:00'并派发change,否则表单会被拦下、后面十几条断言连锁失败——改一处功能,要连带检查所有依赖它的断言。)
补记:同一批里还顺带补上的两处
- E7 的界面入口:「一键重置」原本只有
ItemStore.reset(),界面没有任何入口,演示时要开控制台。现在「我的」页底部有一个「恢复示例数据」按钮,带二次确认(文案Constant.TEXT.confirmReset原本就写好了,一直没接上)。端到端脚本补了三条断言:入口存在、点「取消」不重置、点「确定」回到 24 条。 - 打赏入口刻意复用了同一套弹层模式:给招领帖加打赏时,弹层沿用「档位选中态 + 行内红字错误提示 + 底部两个按钮」这套已经验证过的写法,没有再犯问题 2 那种「有提示、没出口」的错——发布成功页的相似提示、打赏成功后的 toast,每个提示后面都跟着一个明确的结果或入口。
本节配图位置:D:\Lost and Found\_verify\shots\(08 / 09 / 10 / 11 / 12 / 13 / 15 / 16 / 17 共 9 张)
本节自检脚本:D:\Lost and Found\_verify\shoot_extras.py
五、目录说明与使用说明
5.1 目录是如何组织的(2.5 分)
仓库里只放「运行所需的文件」,开发者本地的单测脚本、自检脚本、工作文档全部在仓库之外(并写进 .gitignore 防御)。助教把项目拿到本地后,看到的就是一棵干净、每个文件都有明确职责的树:
102401114-102401126/
├── index.html # 唯一页面:六个视图(首页 / 发布 / 搜索 / 详情 / 我的发布 / 发布成功)+ 底部导航
├── css/
│ └── style.css # 全局样式:主题色 #07C160、卡片体系、表单与标签、移动端自适应
├── js/
│ ├── constant.js # 【契约文件】全局枚举与文案字典:类型 / 状态 / 8 个分类 / 8 个地点 / 排序 / 字数上限 / 提示文案
│ ├── seed.js # 24 条示例数据:覆盖 8 个分类与 8 个地点,含本人 / 他人、进行中 / 已完结、悬赏与打赏的组合
│ ├── reward.js # Reward:悬赏与打赏的读写与统计(纯函数:记录只增不改、累计值派生)
│ ├── store.js # ItemStore:信息存取(getAll / getById / save / update / addTip / remove / isOwner / reset)+ 发布者标识
│ ├── validate.js # Validate:发布表单校验,返回 { valid, errors },逐字段给中文提示
│ ├── search.js # Search:关键词搜索 + 类型/分类/地点/状态组合筛选 + 三种排序
│ ├── status.js # Status:状态与类型的展示文案(进行中 / 已找到 / 已归还……)
│ └── app.js # 页面层:视图路由、列表渲染、表单提交、详情交互、打赏弹层、Toast 提示
├── test/ # 单元测试(Mocha + Chai,7 个文件 181 个用例)
│ ├── constant.test.js # 常量契约:枚举齐全、8 分类 8 地点、长度上限与页面 maxlength 一致
│ ├── seed.test.js # 示例数据:字段结构、分类/地点覆盖度、本人与他人条数
│ ├── store.test.js # ItemStore:9 个方法的正常路径与兜底、localStorage 落盘/损坏自愈/降级
│ ├── validate.test.js # 表单校验:必填、长度边界、文案随类型切换、非法入参兜底
│ ├── search.test.js # 搜索筛选排序:四字段匹配、不搜隐私字段、组合交集、排序回落
│ ├── status.test.js # 状态文案:进行中/已找到/已归还,非法输入返回空串
│ ├── reward.test.js # 悬赏与打赏:金额边界、脏数据兜底、累计值派生、入口判定
│ └── helpers/
│ └── fixtures.js # 测试公用夹具(加载逻辑层模块、示例数据规模、合法表单)
├── package.json # 只声明测试脚本(npm test)与 mocha / chai 依赖,页面运行不需要它
├── README.md # 项目介绍 + 目录说明 + 使用说明(与本节内容一致)
└── .gitignore # 忽略 node_modules、本地自检脚本目录、编辑器与系统文件
组织思路:
- 按「层」分目录,而不是按「页面」分文件。 这一版是单页应用(六个视图在同一个
index.html里用active类切换),所以 CSS 与 JS 各自集中一份即可,方便两人分工:甲改js/的逻辑模块,乙改index.html/css//app.js,文件不重叠,也就不会冲突。 js/内部按「依赖顺序」而不是按「字母顺序」排列,文件名本身就是加载顺序:constant → seed → reward → store → validate → search → status → app。每个模块头部都有依赖守卫,顺序错了会直接抛异常并告诉你该把谁放前面。- 契约文件单独一个
constant.js。 枚举(8 分类、8 地点)、文案、字数上限全部集中在这里,页面层与逻辑层都从这里取——改一处,两个人都生效,不会出现「下拉框 8 个、校验认 7 个」的漂移。 - 测试放
test/,与js/平级。 测试文件require('../js/xxx.js')加载的就是页面运行时用的同一份代码,不存在「副本」。
5.2 测试人员如何运行(2.5 分)
第一步:运行网页(不需要任何环境)
- 从 GitHub 仓库下载 ZIP,或
git clone https://github.com/1cecream1/102401114-102401126.git; - 用 Google Chrome 打开根目录下的
index.html(双击即可); - 页面自带 24 条示例数据,打开就有一屏内容,不需要先发布任何东西。
不需要安装依赖、不需要启动服务器、不需要配置任何东西。页面按移动端(约 375px)宽度设计,建议把浏览器窗口调窄,或用 F12 的移动端模拟查看,能看到完整效果。
第二步:按这张表把每个功能都点一遍(也欢迎助教用这条 30 秒路径快速验收)
| 想看什么 | 怎么操作 |
|---|---|
| 浏览 / 筛选 / 排序 | 打开即首页;点顶部「全部 / 寻物 / 招领」切换类型,用下方三个下拉框按分类、地点、状态筛选(可叠加),点「最新发布 / 最近丢失 / 最多浏览」切换排序 |
| 查看详情与联系方式 | 点任意一张卡片进详情页;点「查看联系方式」显示明文,再点「复制联系方式」复制 |
| 发布一条信息 | 点底部导航中间的「+」→ 选「我丢了东西 / 我捡到东西」→ 填物品名称、分类、地点、时间(原生日期时间选择器)、联系方式(带 * 为必填)→ 点「立即发布」→ 进入发布成功页(若是寻物且库里有同类招领,这里会出现一个可点击的「先去看看」入口) |
| 悬赏与打赏 | 发布寻物信息时可填「悬赏金额」(选填);点开别人的招领帖详情页可看到「🎁 打赏拾金不昧者」,点开弹层选档位或自填金额 |
| 搜索物品 | 点首页顶部搜索框或底部导航进搜索页,输入关键词后点「搜索」或回车;也可以点「热门:」后面的词直接搜 |
| 管理自己的发布 | 点底部导航右侧「我的」,卡片上可「标记为已找到 / 已归还」「撤销完结」「删除」,也可进详情页操作;页面底部有「恢复示例数据」(二次确认) |
| 数据持久化 | 发布/完结/删除后按 F5 刷新,数据仍在(localStorage) |
第三步:跑单元测试(可选,需要 Node.js)
页面运行不需要 Node.js;只有想复现单元测试时才需要:
npm install # 首次运行,安装 mocha 与 chai(联网)
npm test # 运行全部单元测试
预期输出 181 passing。详见第六节。
六、单元测试
6.1 测试工具与学习过程(附简易教程)
工具选型:Mocha + Chai。 理由有三条:
- 对原生 JS 友好。 我们的逻辑层是七个 IIFE 模块(
constant/seed/reward/store/validate/search/status),本来就有「浏览器挂window、Node 用module.exports」的双导出写法。Mocha 直接require就能测,不需要 Babel、不需要构建,也不需要在测试里eval读文件。 - 学习成本低、结构直观。
describe/it两层结构读起来就是「模块 → 用例」,expect(...).to.equal(...)的断言链比assert可读性更好。 npm test一条命令跑完,和作业要求一致,结果里直接给181 passing。
学习过程。 我先照着课程附录里提到的廖雪峰的 JS 教程把 describe / it / 断言的基本写法过了一遍,再读《Mocha 实例教程》确认了「怎么组织测试文件、怎么让 npm test 找到它们」,然后才开始设计用例。真正花时间的不是框架语法,而是想清楚每个函数该测哪些分支——也就是 6.3 那一段。
简易教程(我们自己用到的最小集):
npm init -y # 1. 生成 package.json
npm i -D mocha chai # 2. 装开发依赖(-D:只用于开发,页面运行不需要)
// 3. package.json 里把测试入口固定下来,之后统一用 npm test
{ "scripts": { "test": "mocha test/*.test.js" } }
// 4. test/xxx.test.js —— 用 describe 分组、it 写用例、expect 断言
const { expect } = require('chai');
const C = require('../js/constant.js');
describe('Constant 常量契约', function () {
it('8 个分类、8 个地点,均无重复', function () {
expect(C.CATEGORY).to.have.lengthOf(8);
expect(new Set(C.CATEGORY).size).to.equal(8);
});
});
npm test # 5. 跑,看到一串 ✓ 和 passing 即通过
6.2 测试代码、被测函数与运行结果
测试代码在仓库的 test/ 目录下,共 7 个用例文件、181 个用例,全部通过:
| 测试文件 | 用例数 | 被测对象 / 关注点 |
|---|---|---|
test/constant.test.js |
15 | constant.js 常量契约:枚举齐全、8 分类 8 地点、长度上限与页面 maxlength 一致 |
test/seed.test.js |
22 | seed.js 示例数据:15 个字段结构、分类 / 地点覆盖度、本人与他人条数、closedAt 与状态自洽 |
test/store.test.js |
44 | ItemStore 九个方法:正常路径 + 兜底,以及 localStorage 落盘 / 损坏自愈 / 写失败降级 |
test/validate.test.js |
21 | Validate.validateItem:必填、长度边界、提示文案随类型切换、非法入参兜底 |
test/search.test.js |
47 | Search.filterItems:四字段匹配、「不搜什么」、组合交集、三种排序与非法值回落 |
test/status.test.js |
5 | Status 状态文案:进行中 / 已找到 / 已归还,非法输入返回约定值 |
test/reward.test.js |
27 | Reward:悬赏 / 打赏的金额边界、脏数据兜底、累计值派生、canTip 入口判定 |
关键前提:测的就是页面跑的那份代码。 因为七个模块都写成「浏览器挂 window / Node 用 module.exports」,测试里 require('../js/store.js') 拿到的是同一份实现,而不是为测试另抄一遍。store.js 还主动做了降级:Node 里没有 localStorage 时自动换成进程内的内存替身,所以测试不需要 mock DOM、不需要 jsdom、也不需要额外的配置文件。
下面挑三个最能说明问题的片段。
① 用测试把「不搜什么」钉死(test/search.test.js)
关键词搜索实现里,参与匹配的字段是写死在 search.js 里的四个:
// js/search.js
const KEYWORD_FIELDS = ['name', 'category', 'place', 'description'];
「联系方式不参与搜索」是一条容易被后来者顺手改掉的设计约定(毕竟 contact 也在数据里),所以我们专门为它写了一条用例,谁把它加进 KEYWORD_FIELDS,测试立刻红:
it('不搜联系方式(隐私字段不参与匹配)', function () {
expect(Search.filterItems(ITEMS, { keyword: 'wx-secret' })).to.deep.equal([]);
});
it('不搜 type / status 这类内部枚举值', function () {
expect(Search.filterItems(ITEMS, { keyword: 'lost' })).to.deep.equal([]);
});
这两条用的是一小段自造夹具(3 条数据,每条只在一个字段上有独特关键词),而不是直接用 24 条示例数据——这样「命中」和「不命中」都来自我们已知的字段,不会因为示例数据里恰好出现某个词而「碰巧通过」。集成验证则另开一组用例,拿真实 24 条数据核对条数(如「关键词『图书馆』命中 4 条,而按地点筛选只有 3 条」,恰好证明描述字段也参与了匹配)。
② 测试真实的 localStorage 路径,包括它坏掉的样子(test/store.test.js)
store.js 里有一段开箱即用的持久化逻辑,光靠「换台机器试试」是测不全的(隐私模式、配额写满、数据被别的东西写坏,都不是随手能造的场景)。所以我们往 globalThis 上挂一个可控的假 localStorage,删掉 require 缓存后重新装载 store.js,就能精确构造这些场景:
function withFakeStorage(fakeStorage, fn) {
globalThis.localStorage = fakeStorage;
try { return fn(freshStore()); }
finally { delete globalThis.localStorage; MODULE_FILES.forEach(clearCache); }
}
it('有可用的 localStorage 时,三个键都真的落盘,内容是合法 JSON', function () {
withFakeStorage(makeFakeStorage(), function (store) {
store.getAll(); // 触发首次灌入
const raw = store.save(validForm({ name: '落盘测试' }));
const keys = Object.keys(globalThis.localStorage.map);
expect(keys).to.include(C.STORAGE_KEY);
expect(keys).to.include(C.OWNER_KEY);
expect(keys).to.include(C.SEED_FLAG_KEY);
expect(JSON.parse(globalThis.localStorage.getItem(C.STORAGE_KEY))).to.have.lengthOf(25);
});
});
it('localStorage 里的数据损坏(非法 JSON)时自愈:重新灌入示例数据,不白屏', function () {
const fake = makeFakeStorage();
fake.map[C.STORAGE_KEY] = '{ 这不是合法 JSON';
withFakeStorage(fake, function (store) {
expect(store.getAll()).to.have.lengthOf(24);
expect(JSON.parse(globalThis.localStorage.getItem(C.STORAGE_KEY))).to.have.lengthOf(24);
});
});
it('localStorage 写入抛异常(配额满 / 隐私模式)时降级到内存,不抛异常', function () {
const throwing = { getItem: () => null, setItem: () => { throw new Error('QuotaExceededError'); }, removeItem: () => {} };
withFakeStorage(throwing, function (store) {
expect(function () { store.getAll(); }).to.not.throw();
expect(store.save(validForm()).name).to.equal('学生证');
});
});
这三条对应的都是用户真实会遇到、但我们手动点不出来的情况。
③ 长度边界用「刚好通过 + 超一个字」成对写(test/validate.test.js)
表单校验最容易出的错是「边界差一」(> 写成 >=)。所以凡是长度限制,一律成对写:上限值必须通过、上限 + 1 必须报错,并且错误提示里要带上真实字数:
it('物品名称:必填;30 字通过,31 字报错', function () {
expect(Validate.validateItem(validForm({ name: repeat('字', C.LIMITS.name) })).valid).to.equal(true);
const tooLong = Validate.validateItem(validForm({ name: repeat('字', C.LIMITS.name + 1) }));
expect(tooLong.errors).to.have.property('name');
expect(tooLong.errors.name).to.contain(String(C.LIMITS.name + 1)); // 提示里要带实际字数
});
④(附带亮点)拿测试守住两份文件之间的一致性(test/constant.test.js)
constant.js 里的 LIMITS 和 index.html 上 input 的 maxlength 是两处独立维护的数字,很容易改一边忘一边。与其靠人记得,不如让测试去读 index.html 来核对:
it('长度上限与 index.html 的 maxlength 一致(防止两处各自漂移)', function () {
const html = fs.readFileSync(path.join(JS_DIR, '..', 'index.html'), 'utf8');
const maxlengths = Array.from(html.matchAll(/maxlength="(\d+)"/g)).map(function (m) { return Number(m[1]); });
expect(maxlengths).to.include(C.LIMITS.name);
expect(maxlengths).to.include(C.LIMITS.description);
});
运行结果:npm test 输出 181 passing (134ms),无失败、无跳过(毫秒数每次运行略有浮动)。下面节选一段实际输出:
ItemStore 持久化介质
√ Node 环境(没有 localStorage)自动降级为内存 shim,读写照常
√ 有可用的 localStorage 时,三个键都真的落盘,内容是合法 JSON
√ localStorage 里的数据损坏(非法 JSON)时自愈:重新灌入示例数据,不白屏
√ localStorage 写入抛异常(配额满 / 隐私模式)时降级到内存,不抛异常
Search 关键词:只搜名称 / 分类 / 地点 / 描述四个字段
√ 命中描述(说明不只搜名称)
√ 忽略大小写
√ 不搜联系方式(隐私字段不参与匹配)
181 passing (134ms)

上图说明:
npm test的真实输出(全量 236 行)渲染成的终端画面,出图脚本_verify/shoot_npm_test.py
(脚本直接跑npm test取输出,不改写任何一行,只做终端配色)。为控制图高,图中省略了中间 188 行,并在省略处画出了明确的省略标记;开头的命令与结尾的181 passing均为原样。脚本默认截断,加--full可渲染全量 236 行。
复现方式:在仓库根目录执行npm install后npm test(页面运行本身不需要 Node.js)。
6.3 测试数据构造思路与「刁难」考虑
测试用例按白盒思路设计:先把每个函数的条件分支列出来,再给每个分支配数据。具体分五类:
| 类别 | 做法 | 例子 |
|---|---|---|
| 正常路径 | 合法输入应得到预期结果 | 合法表单 valid === true;关键词「校园卡」命中 2 条 |
| 边界值 | 上限 / 下限刚好取到与差一 | 名称 30 字通过、31 字报错;联系方式 2 字报错、3 字通过 |
| 空值与缺省 | 不传、传空串、传空白串 | filterItems(undefined) 返回 [];名称「三空格」按空处理 |
| 异常输入 | 传错类型、传不存在的 id | save(null) 返回 null;update('不存在的 id') 返回 null;getStatusText(null) 返回 '' |
| 组合与顺序 | 多条件同时生效、排序单调性 | 「招领 + 已完结 = 6 条」;createdAt 严格递减 |
为什么要专门「刁难」异常输入。 因为页面层会把这些函数用在数据已经不可靠的时刻:详情页可能拿一条已经被删掉的记录去取文案,localStorage 里的内容可能被别的东西写坏,搜索框里可能传进 undefined。这些路径一旦抛异常,表现就是整页白屏。所以我们给七个模块的入口都写了兜底(Array.isArray、data && typeof data === 'object'、|| ''),并为每一条兜底都补了一条用例——store.test.js 的 44 条用例里,有一批专门盯这类「非法输入或存储异常时不抛异常」的场景:传入 undefined/null、查不存在的 id、changes 传非法值、JSON 损坏、setItem 抛异常、存储探测不通过……每一条都断言「不抛异常且返回约定值」。reward.test.js 也一样:打赏记录里混进 null、amount 被改成字符串,都要求不抛异常、且合计不会变成 NaN。
哪些没测,以及为什么。 页面层(js/app.js 与 DOM 渲染)没有写单元测试:它依赖真实 DOM、焦点、剪贴板权限和 localStorage,用 jsdom 模拟的投入产出比不高,而且很多问题(比如第四章 4.5 提到的复制降级 bug)恰恰只在真实浏览器里才暴露。这一层我们的做法是手动走查 + 一个自研的浏览器端到端脚本(headless Chrome 驱动真实点击,覆盖 88 项断言)。所以整体策略是:逻辑层用自动化测试锁死,交互层用真实浏览器走查——各用各的长处。
一个真实的收益。 把示例数据的期望条数(24 条 / 本人 6 条 / 寻物 14 条 / 招领 10 条 / 已完结 8 条)写进断言之后,seed.js 的口径就被固定住了:以后任何人改动示例数据,测试会立刻失败并指出是哪一项对不上,而不是等演示时才发现某个筛选下拉点开是空的。这条约束反过来也让第四章的附加功能演示变得可靠——每次演示前跑一次 npm test,就知道数据是否还满足演示前提。
七、GitHub 代码签入记录(1 分)
下图由脚本(_verify/draw_diagrams.py)直接调用 git log 取真实数据渲染而成,不是手工整理:

签入记录说明:
- 共 46 次提交(含 5 次合并提交),甲 26 次、乙 20 次;提交信息统一用「类型 + 描述」的格式:
feat:新功能、fix:修缺陷、test:测试、docs:文档、chore:杂项。 - 每个 commit 只装一个功能点,message 能对上代码:例如
f2a00c7 feat: search.js 关键词扩展到名称/分类/地点/描述四字段对应的就是search.js里KEYWORD_FIELDS从 1 个字段扩到 4 个字段那一次改动。 - 单元测试是独立提交的(
b038d0d test: 单元测试纳入仓库(Mocha + Chai,6 个文件 119 个用例)),不是和功能代码混在一起。 - 合并走 Pull Request:乙 fork 主仓库后在分支上开发,通过 PR #1、PR #3、PR #4 合并进
main(记录里能看到f54640a Merge pull request #1 from diahull/main、636d3cd Merge pull request #3 from diahull/main、d82498e Merge pull request #4 from diahull/main),符合作业 2.1「一人建仓、一人 fork + pull request」的形式要求。 - 如实说明(乙这边走过一段弯路):乙最早有两次提交是通过 GitHub 网页上传完成的(
Add files via upload、Rename app.js to js/app.js),之后又有一批以补充注释为主的提交(style:/refactor:/docs:,合并后的净改动只有几行)。这批记录不够规范——提交次数够了,但「message 与实质改动」是虚的。发现这个问题后,我们按第七节末尾的顺序把第四章 4.5 里那四个真实待改进项逐个做成了独立提交(fix:/feat:,每个都能在代码里指出对应改动),这批记录才立得住。 - 提交数与内容对不上时,宁可少而实。 这一点也写进了第十节的小结。
复现方式:在仓库根目录执行
git log --pretty=format:"%h|%an|%ad|%s" --date=format:"%m-%d %H:%M",输出的就是上图的数据源。
八、遇到的代码模块异常或结对困难及解决方法(4 分)
按「问题描述 → 做过哪些尝试 → 是否解决 → 有何收获」记录四个真实问题。
问题 1:模块名撞上浏览器内置属性,模块会「凭空消失」
-
问题描述:往届学长提醒过「页面渲染不出来,但控制台没有报错」——排查后发现根因是模块挂在了
window的保留属性上,赋值被浏览器悄悄改掉了。 -
做过哪些尝试:我们先在无头 Chrome 里做了一组实测,把候选名字一个个挂在
window上再读回来:尝试的名字 结果 window.status✗ 内置 DOMString 属性, window.status = {…}被强制转成"[object Object]",模块直接消失window.name✗ 同上(内置字符串) window.Storage✗ 赋值前就已存在(构造函数)——这正是往届「模块是否就绪」判断永远为真、列表整个渲染不出来的根因 window.Constant/Seed/ItemStore/Validate/Search/Status✓ 全部可用 -
是否解决:已解决,并从设计层面定成纪律:模块全局名一律大写开头;存取模块刻意避开
Storage,取名ItemStore;页面层判断「模块是否就绪」时检测具体方法(typeof ItemStore.getAll === 'function')而不是判断对象真假。 -
有何收获:「不报错」不等于「正常」。这类冲突是静默失败,如果等到页面白屏了再去猜,排查成本极高。花 20 分钟先做一次环境探测,比事后 debug 两小时划算得多——这也是我们后来坚持「凡是不确定的行为,先写个最小页面实测一遍」的起点。
问题 2:中文日期不能直接用来排序
- 问题描述:做「最近丢失」排序时,排序结果明显不对——
'9月6日'被排在了'10月8日'后面。原因是排序直接用了展示用的中文时间文本,而字符串比较是按字典序的(首字符'9'>'1')。 - 做过哪些尝试:① 写一个中文日期解析函数,把「9月6日 下午」解析成时间戳——但用户输入是自由文本(「上周三」「国庆期间」都是合理输入),解析逻辑永远覆盖不全;② 强制用户从下拉框里选日期——输入体验变差,而且「9月6日」这种粒度反而更符合失物招领的语境;③ 把「展示」和「排序」拆成两个字段。
- 是否解决:已解决。数据里同时存
lostTime(中文文本,只展示)和lostAt(时间戳,只排序),排序时lostAt缺失自动回落到createdAt。示例数据的lostAt按「丢失时间比发布时间早多久」真实反推,所以「最近丢失」和「最新发布」排出来的顺序确实不同(可验证)。 - 有何收获:展示与计算要分开存储。同一个信息在「给人看」和「给机器算」两种用途下,正确的表示形式往往不同;强行用一个字段兼顾,一定会在某一边出错。另外「新发布的记录
lostAt只能用当前时刻近似」这个问题,当时我们选择如实写进 4.5 而不是掩盖;后来也真的把它修掉了——时间字段换成<input type="datetime-local">,lostAt直接取用户选的时刻(见 4.5 问题 3)。
问题 3:第一次跑 npm test,挂了 1 条——错的是测试自己
- 问题描述:当时 119 个用例第一次跑,在
search.test.js上挂了 1 条。断言写的是「命中的 id 应该等于['a','c']」,实际拿到的是['c','a']。(后来用例扩展到 181 个,这条断言也一直保留着。) - 做过哪些尝试:先怀疑排序函数写错了,把
sortItems单独拉出来跑——发现默认排序是「最新发布」(按createdAt倒序),所以c在前本来就是正确行为;错的是我把「集合相等」写成了「顺序相等」。 - 是否解决:已解决。改用
expect(ids).to.have.members(['a','c'])(只校验集合成员、不校验顺序);真正需要校验顺序的用例(如「createdAt严格递减」)则在名字里写明「按时间倒序」,并单独构造时间戳明确的数据。 - 有何收获:测试失败时,先确认验收判据本身是对的。这次的判据错在「默认排序是什么」这个隐含前提上。后来我们在写用例时多了一步:把「这条用例到底在验证什么」写成
it的完整句子(例如「不传 sort 时按 createdAt 倒序」),判据模糊时一眼就能看出来。
问题 4:结对协作中的 Git 流程——从「直接 push」到「必须走 PR」
- 问题描述:作业 2.1 要求「一位同学建仓库,另一位 fork 该项目,有进展时 pull request」。一开始两个人都在主仓库的
main上直接开发,形式上不满足要求;而且双方同时改index.html时会产生冲突。 - 做过哪些尝试:① 甲先用 GitHub 公开 API 确认
main的分支保护状态(GET /repos/.../branches/main返回"protected": true),确认直推会被拒;② 建立「甲在main上推逻辑层 → 乙 fork 后在自己的分支上开发 → 发 Pull Request → 甲 review 后 Merge」的流程;③ 把唯一的公共文件constant.js冻结:接口约定好之后双方都不再改它,其他文件各自独立,从根上消除冲突。 - 是否解决:已解决。乙的页面层通过 PR #1 合并进主仓库,此后双方各自推各自的文件,全程零冲突。
- 有何收获:把「共享状态」压到最小,是两人协作最有效的解药。两个人共用一个文件就必然要频繁沟通,而我们只保留了
constant.js这一个耦合点,其余各自独立——这比「冲突了再想办法解」要省事得多。另外,分支保护虽然一开始让人「推不上去有点烦」,但它实际上强制了 review 环节,是好事。
九、评价队友(2 分)
(颜俊宇)评价(林世颖):
- 值得学习的地方:页面还原度高,样式体系搭得很完整——主题色、卡片阴影、标签配色、空态插画感都是他一个人定的,最后「美观性」这一项基本不需要改。他会从使用者视角提问:我在写逻辑层接口时,他提出「搜索页和首页筛选能不能用同一套规则」「发布页的时间输入框不好填」这类问题,直接影响了后来的接口设计。另外走查很细,第四章 4.5 里那个复制降级的问题就是他先注意到「toast 弹的是失败」,我才去追了代码。
- 需要改进的地方:开发经验不足(虽然本人也是一样hh),有时候commit不太符合规范,但总体态度良好,需一起加油哈_
十、小结
做成了什么
我们完成了作业正文要求的完整闭环——发布信息 → 浏览 / 搜索 → 查看详情 → 联系发布者 → 更新状态,做得比最低要求多一些:8 项附加特点、181 个单元测试用例、24 条可直接演示的示例数据,以及一套「下载后双击 index.html 就能用」的零依赖交付物。
技术上最想留下的一句话
逻辑层与页面层分离 + 模块双导出,这两件事加起来让「单元测试测的就是线上跑的那份代码」成为可能。这不是测试技巧,而是架构决定的:如果逻辑层和界面揉在一起,测试就只能靠 DOM 模拟,覆盖率和可信度都会打折。
过程中的真实教训
- 凡是不确定的行为,先写个最小页面实测——
file://下localStorage/clipboard/crypto.randomUUID到底能不能用,实测 20 分钟得到的结论,直接决定了技术选型能不能成立(见第八节问题 1)。 - 特性检测只能说明 API 存在,不能说明调用会成功——降级路径要挂
catch,不要挂if(见 4.5 问题 1)。 - 测试失败时先确认判据——那次挂掉的用例错在测试自己(见第八节问题 3)。
- 共享文件越少,两人协作越顺——我们全程只保留
constant.js一个耦合点,零冲突(见第八节问题 4)。
遗留的不足(如实列出)
| # | 不足 | 影响 | 目前状态 |
|---|---|---|---|
| 1 | copyContact 的降级路径挂在 else 而不是 catch(4.5 问题 1) |
✅ 已修复:降级改挂 .catch(legacyCopy) |
|
| 2 | #similar-tip 是死元素(4.5 问题 2) |
✅ 已修复:发布成功页给出可点击入口 | |
| 3 | lostAt 用当前时刻近似(4.5 问题 3) |
✅ 已修复:时间字段改 <input type="datetime-local"> |
|
| 4 | ItemStore.reset(),界面上没有入口 |
✅ 已修复:「我的」页加「恢复示例数据」(二次确认) | |
| 5 | 数据存在浏览器本地,不同浏览器 / 设备不互通 | 换设备看不到自己的发布 | 作业已豁免后端,属刻意简化;若要扩展可接云端存储 |
| 6 | 乙早期的提交以注释 / 网页上传为主,粒度偏粗(见第七节第 5 条) | 签入记录规范性有瑕疵(后期已补 4 个实质提交,但早期那几条留在历史里) | 下次从一开始就按功能拆分提交,提交数宁少勿虚 |
| 7 | 页面层没有单元测试,靠端到端脚本 + 手动走查覆盖 | 交互层的回归依赖脚本执行 | 可考虑引入 Playwright 做真实浏览器回归 |
写完博客初稿、又补完悬赏打赏那批功能之后,我们把上面第 1~4 条实际修掉了(改法与验证见 4.5,端到端断言也从 78 项加到 88 项)。所以现在这一表里剩下的只有第 5~7 条。
最后
这份作业最大的收获不是「写出了一个能跑的网页」,而是第一次完整体验了「先约定接口 → 两人并行 → 各自测试 → 合并交付」这套流程。尤其是「把不确定的事先验证掉」和「把共享状态压到最小」这两条经验,比具体写了多少行代码更有价值。
附:本地辅助工具目录(不上传仓库)
目录 / 文件 用途 _localtest/check_constant.jsconstant.js契约自检(42 个导出键 + 55 条断言)_localtest/smoke_logic.js逻辑层四条验收流程冒烟(136 条断言) _verify/verify_app.py端到端自检:headless Chrome 真实点击(88 项断言) _verify/shoot_app.py各视图截图(第一章 / 第五章配图) _verify/shoot_extras.py附加特点截图(第四章配图,含悬赏与打赏) _verify/shoot_npm_test.pynpm test输出截图(第六章配图)_verify/shoot_reward.py悬赏 / 打赏专项截图(第四章配图备选) _verify/draw_diagrams.py架构图 / 流程图 / 签入记录图(第三章、第七章配图) 仓库里不含以上任何文件——作业要求「下载所有文件后双击
index.html就能展现预期结果」,这些是开发期的工具,不影响运行。

浙公网安备 33010602011771号