2026秋软件工程结对作业(第二次之程序实现)
校园失物招领 —— 结对作业二(福州大学 2026 软件工程实践)
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践(福州大学·计算机与大数据学院) |
| 这个作业要求在哪里 | 2026 秋软件工程结对作业(第二次之程序实现)) |
| 结对同学博客 | 颜俊宇 |
| 本作业博客 | 林世颖 |
| 结对成员 | 学号 102401114(颜俊宇)、学号 102401126(林世颖) |
| 项目仓库 | https://github.com/1cecream1/102401114-102401126 |
| 呈现形式 | WEB 网页(纯前端,用谷歌浏览器直接打开 index.html 即可运行) |
一、具体分工
| 成员 | 主要负责 | 具体产出 |
|---|---|---|
| 颜俊宇(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 | 计划 | ||
| Estimate | 估计这个任务需要多少时间 | 20 | 15 |
| Development | 开发 | ||
| Analysis | 需求分析(包括学习新技术) | 100 | 80 |
| Design Spec | 生成设计文档 | 60 | 35 |
| Design Review | 设计复审 | 40 | 25 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 20 | 15 |
| Design | 具体设计 | 60 | 40 |
| Coding | 具体编码 | 300 | 260 |
| Code Review | 代码复审 | 50 | 35 |
| Test | 测试(自我测试,修改代码,提交修改) | 140 | 90 |
| Reporting | 报告 | ||
| Test Report | 测试报告 | 50 | 25 |
| Size Measurement | 计算工作量 | 15 | 10 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 30 | 20 |
| 合计 | 785 | 650 |
实际与预估的差异说明:
- Analysis 比预估少(100→80):接口约定好之后,页面层的需求边界其实很清楚,不需要像甲那样花时间验证 file:// 环境下 localStorage 和 clipboard 能不能用,那部分主要是甲在做。
- Coding 比预估少(300→260):页面层虽然要处理六个视图,但甲给的接口文档写得很细,字段名和返回结构都固定了,照着写就行;真正花时间的反而是样式微调(底部加号居中、搜索框留白、卡片间距这些),这些零碎活儿加起来大概 40 分钟。
- Test 比预估少(140→90):页面层不写单元测试,主要靠浏览器里手动点一遍和甲写的端到端脚本,走查两个下午就差不多了。
- 合计 650 分钟,比预估 785 少了约 17%,主要省在不需要自己写测试和验证底层环境上。但前期接口对齐那一步如果省掉,后面返工的时间会更多——这是这份 PSP 里最值的一笔投入。
三、解题思路描述与设计实现说明
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, { status: 'done' });closedAt 由 update 自动补上,卡片和详情页的状态标签同步变成「已找到」。
(4)三个关键的工程决策
① 模块「双导出」——决定单元测试能不能与线上同源。
七个模块统一写成:
(function (root) {
const ItemStore = { /* ...真实实现... */ };
if (typeof module !== 'undefined' && module.exports) module.exports = ItemStore;
else root.ItemStore = ItemStore;
})(typeof window !== 'undefined' ? window : globalThis);
于是测试里 require('../js/store.js') 拿到的是页面运行时用的同一份代码。这不是为了「方便测试」才补的写法,而是从一开始就按「同一份实现、两种加载方式」设计的——否则测试很容易退化成「为测试另抄一套实现」,那样测过了也不能说明线上是对的。
② 命名铁律:window 上不能挂小写的通用名。
这是往届踩过、我们提前实测避开的坑(详见第八节问题 1)。结论是模块名一律大写开头:Constant / Seed / Reward / ItemStore / Validate / Search / Status;存取模块刻意叫 ItemStore 而不是 Storage(window.Storage 是浏览器内置构造函数)。每个模块头部还加了依赖守卫,顺序错了直接抛异常,不许静默失败。
③ 时间要存两份:给人看的 lostTime 和给排序用的 lostAt。
一开始想直接用表单里那个中文时间(如「9月25日 下午」)排序,很快发现是错的——按字典序 '9月6日' 大于 '10月8日',但实际前者更早。所以数据里拆成两个字段:lostTime(中文文本,只负责展示)与 lostAt(时间戳,只负责排序),排序时若 lostAt 缺失则回落到 createdAt。
顺带说明:发布表单最初用的是自由文本时间,新数据只能拿「此刻」近似 lostAt,「最近丢失」因此退化。后来把该字段换成了 ,展示文本与排序时间戳由同一个输入派生,两个字段天然一致——见 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)
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 里的内容被别的东西写坏时,页面会自动重新灌入示例数据,而不是白屏。
- 降级是「行为一致、只是不持久化」,不是「功能消失」。内存替身的接口与 localStorage 完全一致,所以上层的 ItemStore 一行都不用改。这也正是单元测试能在 Node 里直接跑的原因——Node 里没有 localStorage,于是自动走了这条降级路径。
片段二:校验结果的「按字段归类」(js/validate.js)
function validateItem(data) {
const errors = {};
const d = (data && typeof data === 'object') ? data : {};
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 字';
return { valid: Object.keys(errors).length === 0, errors: errors };
}
为什么这段值得贴:
- 校验函数不知道 DOM 的存在。它只返回一个大对象,页面层拿着 errors.name / errors.contact 各找各的提示位去渲染。所以「一次提交把所有错都标出来」是天然的。
- 提示文案是动态的,不是静态的。「请选择丢失地点 / 请选择拾获地点」直接从 Constant.getPostTab(type).placeLabel 取,所以寻物态与招领态共用同一份校验代码。
- 错误提示里带真实字数,这个细节后来被直接写成了单元测试的断言——测试会检查提示里是否包含 31 这个数字,防止有人把 > 顺手写成 >=。
片段三:详情页联系方式的「三态渲染」(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 不参与关键词匹配的原因。
- 所有插值的文本都过 escapeHtml()。用户输入的名称、描述、联系方式会原样插进 innerHTML,不转义就等于把 XSS 的门敞开。
四、附加特点设计与展示
作业正文只要求「发布信息 — 浏览/搜索 — 查看详情 — 联系发布者 — 更新状态」这条最小闭环,而评分规则里明确写了「尽情丰富内容」,并且自己举了两个例子:按物品类别、遗失或拾取地点进行筛选,一键复制联系方式。我们以这两项为必做,又补了六项,一共 8 项附加特点。
| # | 附加特点 | 与作业要求的关系 | 入口 |
|---|---|---|---|
| E1 | 分类 / 地点 / 状态组合筛选 | 正文只要求「集中浏览」,没要求筛选 | 首页三个下拉 + 类型 tab |
| E2 | 一键复制联系方式 | 正文只要求「查看联系方式」 | 详情页「复制联系方式」 |
| E3 | 三种排序(最新发布 / 最近丢失 / 最多浏览) | 正文未要求 | 首页排序栏 |
| E4 | 相似物品匹配提示 | 正文未要求 | 发布寻物信息后,成功页给出可点击入口 |
| E5 | 首页数据看板(累计 / 寻物 / 招领 / 已完结比例) | 正文未要求 | 首页统计条 |
| E6 | 信息新鲜度提示 | 正文未要求 | 每张卡片的时间行 |
| E7 | 24 条示例数据首次访问自动灌入 + 一键重置 | 正文未要求,但决定了「打开就能用」 | 自动灌入 / 「我的」页「恢复示例数据」按钮 |
| E8 | 悬赏与打赏(寻物帖挂悬赏、招领帖收打赏) | 正文未要求 | 发布寻物帖填悬赏 / 招领帖详情页打赏 |
4.1 设计的创意独到之处
① 筛选不是「三个下拉」,而是「一个可以叠加的检索入口」。
校园失物招领的真实使用场景里,来找东西的人是带着记忆碎片来检索的:「我大概是在图书馆丢的」「应该算电子产品」。所以类型 tab、分类、地点、状态这四个维度被设计成可以叠加的,而不是各筛各的装饰。实测:24 条示例数据里,叠加「寻物 + 电子产品 + 进行中」后剩 3 条。
它的意义不只是「能筛」,而是在下拉框里就把分类和地点做成了校园的封闭词表(8 个分类 / 8 个地点,写在 constant.js 里被两个人共用),用户填不出五花八门的自由文本,于是筛选才成立、关键词搜索才准。
② 联系方式「先打码、再显示、再复制」,三步各解决一个问题。
失物招领的联系方式一旦明文挂在列表上,就等于把同学的微信号交给爬虫。所以详情页默认显示 ●●●●●●●●,点「查看联系方式」才明文显示(多一次明确的用户意图),再点「复制联系方式」直接拿走——省掉手动选字复制的每一步。
③ 让「发布」和「浏览」互相导流:相似物品提示。
发寻物信息的人最怕的一种情况是:发完之后才发现,其实早就有人捡到并发了招领。所以发布寻物信息时,系统拿刚填的分类去招领库里查一遍,命中就在发布成功页给出提示,并且这条提示本身就是一个入口:
库里有 2 条同类的招领信息,可能已被捡到,先去看看 ›
点「先去看看 ›」直接跳进那条招领帖的详情页。这条提示的价值在于把两条本该相遇的信息提前撞上,减少的是「重复发布 → 无人应答 → 反复刷新」的无效流程。
④ 数据看板与新鲜度提示,回答「这个平台有人在用吗」。
首页顶部给出 4 个数字(24 条累计 / 14 条寻物 / 10 条招领 / 33% 已完结),「已完结比例」尤其重要——它是这个平台是否真的在解决问题的证据;每张卡片上则给出「刚刚发布 / N 分钟前 / N 小时前 / N 天前发布」,超过 30 天的信息直接提示「发布时间已超过 30 天,信息可能已过时」。
⑤ 悬赏与打赏:「承诺」和「兑现」分别挂在两种帖子上(E8)。
失物招领里其实有两种完全不同的「给钱」动机,我们没有把它们混成一个功能:
- 悬赏挂在寻物帖上——失主在发布时说「找回后自愿兑现 ¥N」。它是一句承诺,纯展示、不落任何流水。
- 打赏挂在招领帖上——失主看到别人拾金不昧,在详情页点「打赏拾金不昧者」,落一条本机记录,形成一面打赏墙。
这样切分的好处:语义不重叠,承诺归承诺、兑现归兑现;顺手挡掉「自己给自己打赏」的怪逻辑——所以 Reward.canTip(item, isOwner) 明确要求「招领帖 + 非本人」才给出入口。
为什么是「记录」而不是「支付」:这个项目是纯静态、零后端的,真实转账不可能闭环。与其做一个假的支付按钮,不如把界面上如实标注「演示:打赏仅在本机记录,不涉及真实支付」——这是诚实,不是缩水。
一个设计取舍:打赏总额由 tips 数组派生(Reward.getTipTotal(item)),而不是另存一个 tipTotal 字段。多存一个字段就多一个「改了数组忘改总额」的漂移源,而派生值永远不会自相矛盾。
4.2 实现思路
总原则:附加特点不允许破坏核心闭环。所以这 8 项里没有一项需要新增数据表或新增页面——它们要么是在已有结构上多挂一个筛选维度,要么是复用逻辑层已有的纯函数,要么是一个纯展示函数。这样做的直接好处是:附加特点的改动只落在 app.js、reward.js 与 constant.js 里,逻辑层的数据结构与原有接口一行都没动。
(1)组合筛选:把所有筛选项收敛成一个 filters 对象。
const items = Search.filterItems(all, {
type: state.homeTab, category: state.filterCategory,
place: state.filterPlace, status: state.filterStatus, sort: state.sort
});
关键词搜索走的是同一个函数,所以「搜索页」和「首页筛选」永远不会出现判定不一致——规则只有一份。
(2)排序:把「按什么排」写成数据,而不是写成 if。
{ value: 'latest', label: '最新发布', field: 'createdAt', fallbackField: 'createdAt' },
{ value: 'lostTime', label: '最近丢失', field: 'lostAt', fallbackField: 'createdAt' },
{ value: 'hot', label: '最多浏览', field: 'views', fallbackField: 'views' }
排序函数只负责「按 opt.field 取值、一律倒序」,加一种排序只要在表里加一行。
这里踩过一个坑:数据里有两个时间——lostTime(给用户看的中文)和 lostAt(时间戳)。一开始想直接拿 lostTime 排序,但中文日期按字典序比大小是错的('9月6日' 大于 '10月8日',而实际上前者更早)。所以展示与排序必须分成两个字段。
(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
});
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');
}
(5)示例数据:把「打开就能用」也当成一个功能来做。
store.js 的 ensure() 在读不到数据时不只是返回空数组,而是灌入 24 条示例数据并落盘,并且把 JSON 解析失败也归到这条路径上。ItemStore.reset() 则给答辩演示用:一条命令恢复 24 条示例数据。
(6)新鲜度与看板:纯函数 + 一次遍历。
时间文案由 fmtTime(ts) 一个纯函数处理(<1 分钟 / <60 分钟 / <24 小时 / <30 天 / ≥30 天 五档);看板则是对 ItemStore.getAll() 的一次遍历,算出 4 个数字。
(7)悬赏与打赏:新加一个纯函数模块,页面层一行 localStorage 都没碰。
打赏的记录读写与统计被收敛进新模块 js/reward.js,页面层只做两件事:把用户选的金额交给纯函数算出一条记录,再交给 ItemStore.addTip() 落盘。金额判据只有一处(Constant.isValidAmount),累计值派生而非存储,弹层复用同一套模式。
4.3 重要代码片段
片段一:组合筛选 + 排序(js/search.js)
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 (keyword && !matchKeyword(item, keyword)) return false;
return true;
});
return sortItems(hit, f.sort);
}
为什么这段值得贴:六个维度是同一条式子;isAll() 把「没选」的四种写法统一了;先 slice() 再排序,不改调用方的数组;非法输入一律兜底返回约定结果。
片段二:一键复制联系方式(js/app.js)
window.copyContact = function (text) {
const ok = function () { toast(Constant.TEXT.copySuccess); };
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);
} else {
legacyCopy();
}
};
为什么这段值得贴:同一个能力有两条路径,主路径靠特性检测选;降级用的 textarea 必须脱离布局流、不可见但可选中;降级挂在 .catch 上而不是只挂在 else 上。
片段三:打赏累计值派生而非存储(js/reward.js)
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;
}
为什么这段值得贴:不存冗余字段,派生值永远不会对不上;入口判定是一个显式函数而不是散在页面里的 if;脏数据兜底,amount 被改成字符串时记录仍计入条数但不计入金额。
4.4 实现成果展示
下面 9 张图由自检脚本 _verify/shoot_extras.py 一次性跑出来(headless Chrome 真实渲染 + 真实点击):
① 组合筛选:类型「寻物」+ 分类「电子产品」+ 状态「进行中」→ 24 条中剩 3 条

② 排序切换:「最多浏览」——排在最前的是浏览量最高的蓝牙耳机(88 次)

③ 一键复制联系方式——点完弹出「联系方式已复制,去联系 TA 吧」

④ 相似物品匹配提示——发布成功页给出可点击的「先去看看 ›」

⑤ 搜索空态 + 热门词——搜「航空母舰」0 条结果,给出空态引导

⑥ 信息新鲜度与过时提示——33 天前的信息直接提示可能已过时

⑦ 悬赏:只挂在寻物帖上——保温水杯详情页显示橙色悬赏行,没有打赏按钮

⑧ 打赏:只挂在别人的招领帖上——点按钮后弹层从底部升起

⑨ 「我的」页底部的「恢复示例数据」按钮——演示前不用再开控制台

4.5 自查发现的问题与修复
写这一节时我逐项点了自己实现的功能,抓到三个问题。它们在截图里看不出来,是"点下去"才暴露的。后来我们把这三条实际修掉了,每一条都补了对应的端到端断言。下面保留「当时是怎么发现的」,再写「最后怎么修的」。
问题 1:复制的降级路径挂错了位置
- 现象:用脚本自动点击「复制联系方式」时,toast 弹的是「复制失败」;而 window.isSecureContext 是 true、navigator.clipboard 也存在——特性检测通过,调用却失败了。
- 原因:.catch(fail) 只弹一句"复制失败",真正能用的 execCommand 降级写在 else 分支里,只有 navigator.clipboard 不存在时才轮到它。
- 修法:把降级抽成函数,if 的两个分支和 catch 都指向它(代码见 4.3 片段二)。
- 状态:已修复。
- 收获:特性检测只能说明"这个 API 存在",不能说明"这次调用会成功"。这个 bug 是靠"用脚本真的去点一下"发现的——人在浏览器里手点复现不出来。
问题 2:相似物品提示只有提示,没有出口
- 现象:文案写着「先去看看?」,但它是一条 1.8 秒后自动消失的 toast,点不了;index.html 里那个
- 修法:把 #similar-tip 真正用起来,渲染在发布成功页,并用闭包绑 onclick(因为 openDetail 是 app.js 内部函数,没挂到 window,写 onclick="openDetail(...)" 会静默失效)。
- 状态:已修复。
- 收获:给出了建议,就要同时给出执行建议的入口。另外「内部函数不要通过 HTML 属性调用」这类错误不会报错,只会让点击悄无声息地失效。
问题 3:「最近丢失」排序对新发布的信息会退化成「最新发布」
- 现象:示例数据的 lostAt 是反推的时间戳,排序有区分度;但新发布时时间字段是自由文本,app.js 只能拿当前时刻近似 data.lostAt = Date.now()。
- 修法:把时间字段换成 ,lostAt = new Date(value).getTime(),lostTime 由该时间戳格式化成中文(凌晨/早上/上午/中午/下午/傍晚/晚上七档,和示例数据同一种措辞)。
- 状态:已修复。
- 收获:展示与计算要分开存储。新发布的记录如果不把时间戳和展示文本同步写,排序就会出现一个"只有对示例数据才成立"的假象。
五、目录说明与使用说明
5.1 目录是如何组织的
102401114-102401126/
├── index.html # 唯一页面:六个视图 + 底部导航
├── css/
│ └── style.css # 全局样式:主题色 #07C160、卡片体系、移动端自适应
├── js/
│ ├── constant.js # 【契约文件】枚举与文案字典
│ ├── seed.js # 24 条示例数据
│ ├── reward.js # Reward:悬赏与打赏的纯函数
│ ├── store.js # ItemStore:存取 + 发布者标识
│ ├── validate.js # Validate:表单校验
│ ├── search.js # Search:搜索 / 筛选 / 排序
│ ├── status.js # Status:状态文案
│ └── app.js # 页面层:视图路由、渲染、事件绑定
├── test/ # 单元测试(7 个文件 181 个用例)
├── package.json
├── README.md
└── .gitignore
组织思路:按「层」分目录而不是按「页面」分文件;js/ 内部按依赖顺序排列(文件名本身就是加载顺序);契约文件单独一个 constant.js;测试放 test/,与 js/ 平级,require 的就是页面运行时同一份代码。
5.2 测试人员如何运行
第一步:运行网页
- 从 GitHub 仓库下载 ZIP;
- 用 Google Chrome 打开根目录下的 index.html(双击即可);
- 页面自带 24 条示例数据,打开就有内容。
不需要安装依赖、不需要启动服务器。页面按移动端宽度设计,建议把浏览器窗口调窄或用 F12 移动端模拟查看。
第二步:按这张表把每个功能点一遍
| 想看什么 | 怎么操作 |
|---|---|
| 浏览 / 筛选 / 排序 | 打开即首页;切类型 tab,用三个下拉按分类、地点、状态筛选,点排序栏切换 |
| 查看详情与联系方式 | 点任意卡片进详情页;点「查看联系方式」显示明文,再点「复制联系方式」 |
| 发布一条信息 | 点底部「+」→ 选类型 → 填名称、分类、地点、时间、联系方式 → 立即发布 |
| 悬赏与打赏 | 发布寻物时可填悬赏金额;点开别人的招领帖详情页可看到打赏按钮 |
| 搜索物品 | 点首页搜索框进搜索页,输入关键词回车;或点热门词直接搜 |
| 管理自己的发布 | 点底部「我的」,卡片上可标记已找到/已归还、撤销、删除;底部有「恢复示例数据」 |
| 数据持久化 | 发布/完结/删除后按 F5 刷新,数据仍在 |
第三步:跑单元测试(可选,需要 Node.js)
npm install
npm test
预期输出 181 passing。
六、单元测试
6.1 测试工具与学习过程
工具选型:Mocha + Chai。理由:对原生 JS 友好(七个模块本来就有双导出写法)、学习成本低(describe/it 两层结构直观)、npm test 一条命令跑完。
简易教程:
npm init -y
npm i -D mocha chai
{ "scripts": { "test": "mocha test/*.test.js" } }
const { expect } = require('chai');
const C = require('../js/constant.js');
describe('Constant 常量契约', function () {
it('8 个分类、8 个地点,均无重复', function () {
expect(C.CATEGORY).to.have.lengthOf(8);
});
});
6.2 测试代码与运行结果
测试在 test/ 目录下,7 个文件、181 个用例,全部通过:
| 测试文件 | 用例数 | 被测对象 |
|---|---|---|
| constant.test.js | 15 | 常量契约:枚举齐全、长度上限与 maxlength 一致 |
| seed.test.js | 22 | 示例数据:字段结构、分类覆盖、本人/他人条数 |
| store.test.js | 44 | ItemStore:九个方法、localStorage 落盘/损坏自愈/降级 |
| validate.test.js | 21 | 表单校验:必填、长度边界、文案随类型切换 |
| search.test.js | 47 | 搜索筛选排序:四字段匹配、不搜隐私字段、组合交集 |
| status.test.js | 5 | 状态文案 |
| reward.test.js | 27 | 悬赏打赏:金额边界、脏数据兜底、累计值派生 |
关键前提:测的就是页面跑的那份代码。因为七个模块都写成双导出,测试里 require('../js/store.js') 拿到的是同一份实现,不需要 mock DOM。
运行结果:npm test 输出 181 passing (134ms),无失败、无跳过。

上图是 npm test 的真实输出(全量 236 行),为控制图高中间省略了 188 行,开头命令与结尾 181 passing 均为原样。
6.3 测试数据构造思路
测试用例按白盒思路设计:先把每个函数的条件分支列出来,再给每个分支配数据。分五类:正常路径、边界值(上限刚好通过 + 超一个字报错成对写)、空值与缺省、异常输入、组合与顺序。
为什么要专门刁难异常输入:页面层会把这些函数用在数据已经不可靠的时刻——详情页可能拿一条已经被删掉的记录去取文案,localStorage 里的内容可能被别的东西写坏。这些路径一旦抛异常,表现就是整页白屏。所以每个模块入口都写了兜底,每条兜底都补了一条用例。
页面层没有写单元测试:它依赖真实 DOM、焦点、剪贴板权限,用 jsdom 模拟投入产出比不高。这一层的做法是手动走查 + 一个自研的浏览器端到端脚本(headless Chrome 驱动真实点击,覆盖 88 项断言)。
七、GitHub 代码签入记录
下图由脚本直接调用 git log 取真实数据渲染:

签入记录说明:
- 共 46 次提交(含合并提交),甲 26 次、乙 20 次;提交信息统一用「类型 + 描述」格式。
- 每个 commit 只装一个功能点,message 能对上代码。
- 单元测试是独立提交的,不跟功能代码混在一起。
- 合并走 Pull Request:乙 fork 主仓库后在分支上开发,通过 PR #1、#3、#4 合并进 main,符合作业要求。
- 如实说明:乙最早有两次提交是通过 GitHub 网页上传完成的,之后又有一批以补充注释为主的提交。这批记录不够规范——提交次数够了,但 message 与实质改动是虚的。发现这个问题后,我们把 4.5 里那四个真实待改进项逐个做成了独立提交,这批记录才立得住。
八、遇到的代码模块异常或结对困难及解决方法
问题 1:模块名撞上浏览器内置属性
- 问题描述:页面渲染不出来,但控制台没有报错——模块挂在了 window 的保留属性上,赋值被浏览器悄悄改掉了。
- 尝试:在无头 Chrome 里实测,window.status 被强制转成字符串、window.Storage 赋值前就已存在。
- 解决:模块全局名一律大写开头,存取模块取名 ItemStore 避开 Storage;判断模块是否就绪时检测具体方法而不是判断对象真假。
- 收获:不报错不等于正常。花 20 分钟先做环境探测,比事后 debug 两小时划算。
问题 2:中文日期不能直接用来排序
- 问题描述:做「最近丢失」排序时,'9月6日' 被排在 '10月8日' 后面,因为字符串按字典序比较。
- 尝试:写中文日期解析函数(覆盖不全)、强制下拉选择(体验差)、把展示和排序拆成两个字段。
- 解决:数据里同时存 lostTime(中文文本,只展示)和 lostAt(时间戳,只排序)。
- 收获:展示与计算要分开存储。新发布记录的时间戳和展示文本必须同步写。
问题 3:第一次跑 npm test 挂了 1 条——错的是测试自己
- 问题描述:119 个用例第一次跑,search.test.js 挂了 1 条,断言写的是「id 应该等于 ['a','c']」,实际拿到的是 ['c','a']。
- 尝试:怀疑排序函数写错了,单独拉出来跑——发现默认排序是按 createdAt 倒序,c 在前本来就是正确行为,错的是把「集合相等」写成了「顺序相等」。
- 解决:改用 expect(ids).to.have.members(['a','c'])。
- 收获:测试失败时,先确认验收判据本身是对的。
问题 4:结对协作中的 Git 流程
- 问题描述:一开始两个人都在主仓库 main 上直接开发,形式上不满足要求;双方同时改 index.html 时会产生冲突。
- 尝试:建立「甲推逻辑层 → 乙 fork 后在自己分支开发 → 发 PR → 甲 review 后 merge」的流程;把唯一公共文件 constant.js 冻结。
- 解决:全程零冲突。
- 收获:把共享状态压到最小,是两人协作最有效的解药。
九、评价队友
林世颖评价颜俊宇:
- 值得学习的地方:他写逻辑层之前先把接口文档列得很细,每个函数传什么参数、返回什么结构、非法输入给什么提示都提前定好了,我照着写页面层基本没猜过,遇到问题翻文档就能找到答案。有个地方我一开始不太理解——他坚持页面层不许直接碰 localStorage,所有读写都要走他写的 ItemStore。当时觉得多绕了一层,后来才明白正是这条纪律,他那 181 个单元测试才能在 Node 里直接跑,不用模拟浏览器环境。另外他中途主动加了悬赏打赏功能,我走查的时候基本没挑出什么毛病,弹层交互和错误处理都写得很完整。
- 需要改进的地方:接口变更的同步。他中途把关键词搜索从只匹配名称扩到了名称、分类、地点、描述四个字段,又把发布时间改成了必填项,这些都写进了文档,但没有第一时间跟我说。我是后来自己重新看文档才发现的,那天对着搜出来的结果对了半天,还以为是自己代码写错了。后来约好每次改完接口在群里说一声,联调才顺起来。
十、小结
做成了什么:
我们完成了作业要求的完整闭环——发布信息、浏览和搜索、查看详情、联系发布者、更新状态。在这个基础上多做了 8 项附加功能:组合筛选、一键复制联系方式、三种排序、相似物品提示、首页数据看板、新鲜度提示、示例数据一键重置,还有悬赏打赏。测试方面写了 181 个单元测试用例,覆盖逻辑层所有模块。交付物就是一个下载下来双击 index.html 就能用的网页,不需要装任何东西。
过程里印象最深的几件事:
一是"先约定再写代码"比我想象中重要得多。我们一开始花了一个晚上对接口,把每个函数叫什么、参数叫什么都定下来,之后各写各的,几乎没返工。如果当时直接开始写,两个人各写各的,后面联调肯定要重写一大半。
二是有些问题手动点是发现不了的。比如复制联系方式那个 bug,我在浏览器里点十次都是成功的,但他写的自动化脚本一点就复现了——因为无头浏览器里文档没有焦点,剪贴板 API 会调用失败,降级路径又没挂对地方。这种问题靠人工走查真的很难碰到。
三是结对的时候,自己写的代码自己看不出问题。我一开始觉得发布成功后停在那个页面挺正常的,他走查的时候直接问"我发完想看看这条信息长什么样怎么办",我才意识到缺了个"查看详情"的出口。这种互相检查一个人是做不到的。
遗留不足:
| # | 不足 | 状态 |
|---|---|---|
| 1 | copyContact 降级路径挂错位置 | 已修复 |
| 2 | 相似提示是无出口的 toast | 已修复 |
| 3 | 新发布记录 lostAt 用当前时刻近似 | 已修复 |
| 4 | 一键重置没有界面入口 | 已修复 |
| 5 | 数据存在浏览器本地,不同设备不互通 | 作业豁免后端,刻意简化 |
| 6 | 早期提交粒度偏粗,有几次是网页上传和补注释 | 后期已补实质提交 |
| 7 | 页面层没有单元测试,靠端到端脚本加手动走查 | 时间有限,后续可补 |
这份作业最大的收获不是写出了一个能跑的网页,而是第一次完整走了一遍"先约定接口、两个人并行开发、各自测试、最后合并交付"的流程。以前写代码都是自己一个人从头写到尾,这次才体会到把共享部分压到最小、把接口提前冻结,真的能省很多沟通成本。下次做项目应该从一开始就按这个思路来。

浙公网安备 33010602011771号